Merge branch 'pending_changes' into staging_environment

This commit is contained in:
Carter 2026-06-25 12:48:28 -06:00
commit cca981308d
7 changed files with 168 additions and 14 deletions

View file

@ -213,7 +213,8 @@ export default function ComponentSettings(props: Props) {
}, [props.component, componentTypeOptions]);
useEffect(() => {
if (props.component !== prevComponent || (!isDialogOpen && prevIsDialogOpen)) {
if (isDialogOpen && prevIsDialogOpen) return;
if (props.component !== prevComponent || isDialogOpen !== prevIsDialogOpen) {
init();
}
}, [props.component, prevComponent, init, isDialogOpen, prevIsDialogOpen]);

View file

@ -95,6 +95,7 @@ export default function DeviceSettings(props: Props) {
const deviceAPI = useDeviceAPI();
const { device, isDialogOpen, closeDialogCallback, canEdit, refreshCallback, components } = props;
const prevDevice = usePrevious(device);
const prevIsDialogOpen = usePrevious(isDialogOpen);
const [deviceForm, setDeviceForm] = useState<Device>(deviceFromForm(Device.clone(device)));
const [isRemoveDeviceDialogOpen, setIsRemoveDeviceDialogOpen] = useState<boolean>(false);
const [sleeps, setSleeps] = useState<boolean>(device.settings.sleepDurationS > 0);
@ -110,7 +111,8 @@ export default function DeviceSettings(props: Props) {
const [existingMutation, setExistingMutation] = useState<pond.LinearMutation>();
useEffect(() => {
if (prevDevice !== device) {
if (isDialogOpen && prevIsDialogOpen) return;
if (prevDevice !== device || isDialogOpen !== prevIsDialogOpen) {
setDeviceForm(deviceFromForm(Device.clone(props.device)));
if (device.settings.extensionComponents[0]) {
setCompExtOne(device.settings.extensionComponents[0]);
@ -130,7 +132,7 @@ export default function DeviceSettings(props: Props) {
setComponentsByDevice(cbd);
}
}
}, [device, prevDevice, props.device, components]);
}, [device, prevDevice, props.device, components, isDialogOpen, prevIsDialogOpen]);
const close = () => {
closeDialogCallback();

View file

@ -22,6 +22,14 @@ class VersionChip extends React.Component<Props, State> {
variant="outlined"
label={firmwareVersionHelper.description}
icon={firmwareVersionHelper.icon}
sx={
firmwareVersionHelper.colour
? {
borderColor: firmwareVersionHelper.colour,
color: firmwareVersionHelper.colour
}
: undefined
}
/>
</Tooltip>
);

View file

@ -133,6 +133,7 @@ export default function InteractionSettings(props: Props) {
const prevInitialInteraction = usePrevious(initialInteraction);
const prevComponents = usePrevious(components);
const prevInitialComponent = usePrevious(initialComponent);
const prevIsDialogOpen = usePrevious(isDialogOpen);
const classes = useStyles();
const [{ user, as }] = useGlobalState();
const interactionsAPI = useInteractionsAPI();
@ -232,10 +233,12 @@ export default function InteractionSettings(props: Props) {
if (user && user.settings.timezone) {
setTimezone(user.settings.timezone);
}
if (isDialogOpen && prevIsDialogOpen) return;
if (
prevInitialInteraction !== initialInteraction ||
(prevComponents && prevComponents.length !== components.length) ||
getComponentIDString(prevInitialComponent) !== getComponentIDString(initialComponent)
getComponentIDString(prevInitialComponent) !== getComponentIDString(initialComponent) ||
isDialogOpen !== prevIsDialogOpen
) {
setDefaultState();
}
@ -247,7 +250,9 @@ export default function InteractionSettings(props: Props) {
prevInitialComponent,
prevInitialInteraction,
setDefaultState,
user
user,
isDialogOpen,
prevIsDialogOpen
]);
const getAvailableSinks = () => {

View file

@ -4,6 +4,7 @@ import {
CardActions,
CardContent,
CardHeader,
CircularProgress,
darken,
Grid2 as Grid,
IconButton,
@ -14,7 +15,7 @@ import {
Tooltip,
Typography
} from "@mui/material";
import { Settings } from "@mui/icons-material";
import { CheckCircleOutline, Settings } from "@mui/icons-material";
import { useInteractionsAPI, usePrevious, useSnackbar } from "hooks";
import { cloneDeep } from "lodash";
import { Component, Device, Interaction } from "models";
@ -80,11 +81,21 @@ export default function InteractionsOverview(props: Props) {
const [interactions, setInteractions] = useState<Interaction[]>(props.interactions);
const [dirtyInteractions, setDirtyInteractions] = useState<Map<number, boolean>>(new Map());
const [mappedComponents, setMappedComponents] = useState<Map<string, Component>>(new Map());
const [acceptedInteractions, setAcceptedInteractions] = useState<Set<string>>(new Set());
const [renderToggle, setRenderToggle] = useState(false);
const [selectedInteraction, setSelectedInteraction] = useState<Interaction | undefined>(
undefined
);
useEffect(() => {
console.log("[interactions:mount] initial interactions:", props.interactions.map(i => ({
key: i.key(),
synced: i.status.synced,
lastUpdate: i.status.lastUpdate,
lastSynced: i.status.lastSynced,
})));
}, []);
useEffect(() => {
if (components !== prevComponents) {
let initMappedComponents: Map<string, Component> = new Map();
@ -95,6 +106,31 @@ export default function InteractionsOverview(props: Props) {
}
if (props.interactions !== prevInteractions) {
console.log("[interactions:effect] props.interactions changed, prevInteractions was", prevInteractions ? "defined" : "undefined");
props.interactions.forEach((interaction: Interaction) => {
const key = interaction.key();
const prevInteraction = prevInteractions?.find(p => p.key() === key);
console.log("[interactions:effect]", key, {
synced: interaction.status.synced,
lastUpdate: interaction.status.lastUpdate,
prevSynced: prevInteraction?.status.synced,
prevLastUpdate: prevInteraction?.status.lastUpdate,
});
});
setAcceptedInteractions(prev => {
const updated = new Set(prev);
props.interactions.forEach((interaction: Interaction) => {
const key = interaction.key();
const prevInteraction = prevInteractions?.find(p => p.key() === key);
if (!interaction.status.synced) {
updated.delete(key);
} else if (prevInteraction && !prevInteraction.status.synced) {
console.log("[interactions:effect] ACCEPTED", key);
updated.add(key);
}
});
return updated;
});
setInteractions(cloneDeep(props.interactions));
}
}, [components, prevComponents, props.interactions, prevInteractions]);
@ -204,11 +240,28 @@ export default function InteractionsOverview(props: Props) {
interactionsAPI
.updateInteraction(Number(deviceID), settings, as)
.then((_response: any) => {
console.log("[interactions:submit] API success, setting synced=false for index", index);
let updatedDirtyInteractions = cloneDeep(dirtyInteractions);
updatedDirtyInteractions.set(index, false);
setDirtyInteractions(updatedDirtyInteractions);
setInteractions(prev => {
const updated = cloneDeep(prev);
if (updated[index]) {
updated[index].status.synced = false;
updated[index].status.lastUpdate = moment().toISOString();
console.log("[interactions:submit] local state updated for", updated[index].key());
}
return updated;
});
const interactionKey = settings.key ?? "";
if (interactionKey) {
setAcceptedInteractions(prev => {
const updated = new Set(prev);
updated.delete(interactionKey);
return updated;
});
}
success("Successfully updated the interaction for " + component.name());
refreshCallback();
})
.catch((_err: any) => {
error("Error occurred while updating the interaction for " + component.name());
@ -226,10 +279,12 @@ export default function InteractionsOverview(props: Props) {
let key = interaction.key();
let source = mappedComponents.get(componentIDToString(interaction.settings.source));
let sink = mappedComponents.get(componentIDToString(interaction.settings.sink));
let statusText =
!interaction.status.synced && interaction.status.lastUpdate
? "Pending " +
moment(interaction.status.lastUpdate && interaction.status.lastUpdate).fromNow()
const isAccepted = acceptedInteractions.has(interaction.key());
const isPending = !interaction.status.synced;
let statusText = isAccepted
? "Pending change accepted"
: isPending
? "Pending " + moment(interaction.status.lastUpdate).fromNow()
: "";
let schedule = pond.InteractionSchedule.create(
interaction.settings.schedule !== null ? interaction.settings.schedule : undefined
@ -272,7 +327,20 @@ export default function InteractionsOverview(props: Props) {
className={classes.header}
titleTypographyProps={{ variant: "subtitle2" }}
title={interactionResultText(interaction, sink)}
subheader={statusText}
subheader={
statusText ? (
<Grid container spacing={0.5} alignItems="center">
{isPending && <CircularProgress size={10} thickness={5} color="inherit" />}
{isAccepted && (
<CheckCircleOutline
sx={{ fontSize: 13, color: "var(--status-ok)" }}
/>
)}
<Grid>
{statusText}
</Grid>
</Grid>
) : ""}
subheaderTypographyProps={{ variant: "caption" }}
action={action}
/>

View file

@ -282,6 +282,64 @@ export default function DevicePage() {
enabled: !!deviceID,
});
// --- Real-time interaction updates ---
// Streams interaction changes for this device, including status changes
// when the device accepts pending interaction updates.
useWebSocket<{ key: string; interaction: Interaction } | null>({
path: `/v1/live/devices/${deviceID}/interactions`,
parse: (e) => {
try {
const raw = JSON.parse(e.data);
const interaction = Interaction.any(raw);
if (!interaction?.settings) {
console.warn("[ws] received interaction without settings:", raw);
return null;
}
return { key: interaction.key(), interaction };
} catch (err) {
console.warn("[ws] failed to parse interaction:", err);
return null;
}
},
onMessage: (data) => {
if (!data) return;
const { key, interaction } = data;
console.log("[ws:interaction] received", key, {
synced: interaction.status.synced,
lastUpdate: interaction.status.lastUpdate,
lastSynced: interaction.status.lastSynced,
});
setInteractions((prev) => {
const index = prev.findIndex((existing) => existing.key() === key);
if (index < 0) {
return [...prev, interaction];
}
const existing = prev[index];
const existingLastUpdate = getTimestampMillis(existing.status.lastUpdate);
const incomingLastUpdate = getTimestampMillis(interaction.status.lastUpdate);
const incomingLastSynced = getTimestampMillis(interaction.status.lastSynced);
if (existingLastUpdate !== undefined && incomingLastUpdate !== undefined &&
existingLastUpdate > incomingLastUpdate) {
return prev;
}
if (!existing.status.synced && interaction.status.synced &&
incomingLastUpdate !== undefined &&
existingLastUpdate === incomingLastUpdate &&
(incomingLastSynced === undefined || incomingLastSynced < incomingLastUpdate)) {
return prev;
}
const updated = [...prev];
updated[index] = interaction;
return updated;
});
},
keys: liveContextKeys,
types: liveContextTypes,
as: liveAs,
token,
enabled: !!deviceID,
});
const loadPortScan = () => {
deviceAPI.listFoundComponents(parseInt(deviceID))
.then(resp => {
@ -395,7 +453,7 @@ export default function DevicePage() {
interactions={filteredInteractions}
permissions={permissions}
deviceComponentPreferences={prefsMap.get(c.key())}
key={i}
key={c.key()}
refreshCallback={(updatedComponent?: Component) =>
updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice()
}
@ -412,7 +470,7 @@ export default function DevicePage() {
interactions={filteredInteractions}
permissions={permissions}
deviceComponentPreferences={prefsMap.get(c.key())}
key={i}
key={c.key()}
refreshCallback={(updatedComponent?: Component) =>
updatedComponent ? handleComponentChanged(updatedComponent) : loadDevice()
}

View file

@ -9,6 +9,7 @@ export interface FirmwareVersionHelper {
const oldFirmwareColour = "var(--status-warning)";
const currentFirmwareColour = "var(--status-ok)";
const unknownFirmwareColour = "var(--status-warning)";
export function getFirmwareVersionHelper(
version: string,
@ -17,24 +18,35 @@ export function getFirmwareVersionHelper(
const firmwareIcon = <FirmwareIcon />;
const oldFirmwareIcon = <FirmwareIcon style={{ color: oldFirmwareColour }} />;
const currentFirmwareIcon = <FirmwareIcon style={{ color: currentFirmwareColour }} />;
const unknownFirmwareIcon = <FirmwareIcon style={{ color: unknownFirmwareColour }} />;
let helper = {} as FirmwareVersionHelper;
if (!version || version === "") {
helper.description = "Unknown version";
helper.icon = oldFirmwareIcon;
helper.tooltip = "We don't know what version your device is, it may behave unexpectedly";
helper.colour = oldFirmwareColour;
} else if (version.startsWith("!")) {
const cleanVersion = version.substring(1);
helper.description = cleanVersion;
helper.icon = unknownFirmwareIcon;
helper.tooltip = "Firmware " + cleanVersion + " was reported by the device but is missing from the firmware list";
helper.colour = unknownFirmwareColour;
} else if (available !== "" && version !== available) {
helper.description = version;
helper.icon = oldFirmwareIcon;
helper.tooltip =
"A firmware upgrade is available, some features may be disabled for out-of-date devices";
helper.colour = oldFirmwareColour;
} else if (available === "") {
helper.description = version;
helper.icon = firmwareIcon;
helper.tooltip = "Running version " + version;
helper.colour = "";
} else {
helper.description = version;
helper.icon = currentFirmwareIcon;
helper.tooltip = "Your device is running the latest firmware";
helper.colour = currentFirmwareColour;
}
return helper;
}