From 71fb67bba4d22d41ea5d8a6ecb0a94304a326446 Mon Sep 17 00:00:00 2001 From: Carter Date: Tue, 7 Jul 2026 11:28:34 -0600 Subject: [PATCH] pending changes indication for device and component changes --- src/common/PendingChangesChip.tsx | 46 ++++++++++++++++ src/common/PendingChangesIndicator.tsx | 27 +++++++++ src/common/StatusChip.tsx | 20 ------- src/component/ComponentCard.tsx | 40 +++++++++++--- src/device/DeviceOverview.tsx | 12 ++-- src/hooks/index.ts | 1 + src/hooks/usePendingChanges.ts | 31 +++++++++++ src/interactions/InteractionsOverview.tsx | 20 +++---- src/pages/Device.tsx | 24 ++------ src/pages/DeviceComponent.tsx | 64 ++++++++++++++++++++-- src/pbHelpers/Status.tsx | 67 ----------------------- src/utils/index.ts | 1 + src/utils/syncStatus.ts | 41 ++++++++++++++ 13 files changed, 259 insertions(+), 135 deletions(-) create mode 100644 src/common/PendingChangesChip.tsx create mode 100644 src/common/PendingChangesIndicator.tsx delete mode 100644 src/common/StatusChip.tsx create mode 100644 src/hooks/usePendingChanges.ts delete mode 100644 src/pbHelpers/Status.tsx create mode 100644 src/utils/syncStatus.ts diff --git a/src/common/PendingChangesChip.tsx b/src/common/PendingChangesChip.tsx new file mode 100644 index 0000000..f2b393a --- /dev/null +++ b/src/common/PendingChangesChip.tsx @@ -0,0 +1,46 @@ +import { Chip, CircularProgress, Tooltip } from "@mui/material"; +import { CheckCircleOutline } from "@mui/icons-material"; + +interface Props { + pending: boolean; + accepted: boolean; + size?: "small" | "medium"; +} + +/** + * Capsule tag shown alongside the other status chips while changes are + * waiting to be synced to a device or component. Shows a small loading + * circle while pending and a checkmark once the change is accepted. + * Derive the flags with usePendingChanges. + */ +export default function PendingChangesChip(props: Props) { + const { pending, accepted, size } = props; + + if (pending) { + return ( + + } + size={size} + /> + + ); + } + + if (accepted) { + return ( + + } + size={size} + /> + + ); + } + + return null; +} diff --git a/src/common/PendingChangesIndicator.tsx b/src/common/PendingChangesIndicator.tsx new file mode 100644 index 0000000..2d3ff59 --- /dev/null +++ b/src/common/PendingChangesIndicator.tsx @@ -0,0 +1,27 @@ +import { CircularProgress, Grid2 as Grid } from "@mui/material"; +import { CheckCircleOutline } from "@mui/icons-material"; + +interface Props { + pending: boolean; + accepted: boolean; + text: string; +} + +/** + * Small inline status line used inside cards: a loading circle beside the + * caption while changes are pending, and a checkmark once they are accepted. + * Derive the flags with usePendingChanges (or per-item tracking like + * InteractionsOverview does). + */ +export default function PendingChangesIndicator(props: Props) { + const { pending, accepted, text } = props; + if (!pending && !accepted) return null; + + return ( + + {pending && } + {accepted && } + {text} + + ); +} diff --git a/src/common/StatusChip.tsx b/src/common/StatusChip.tsx deleted file mode 100644 index c1b16b3..0000000 --- a/src/common/StatusChip.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { Chip, Tooltip } from "@mui/material"; -import { getStatusHelper } from "pbHelpers/Status"; - -interface Props { - status: string; - size?: "small" | "medium"; -} - -export default function StatusChip(props: Props) { - const { status, size } = props; - const statusHelper = getStatusHelper(status); - const icon = statusHelper.icon; - const description = statusHelper.description; - - return icon !== null ? ( - - - - ) : null; -} diff --git a/src/component/ComponentCard.tsx b/src/component/ComponentCard.tsx index 8355abe..8b9b767 100644 --- a/src/component/ComponentCard.tsx +++ b/src/component/ComponentCard.tsx @@ -13,11 +13,13 @@ import { } from "@mui/material"; import Grid from '@mui/material/Grid2'; import EventBlocker from "common/EventBlocker"; +import PendingChangesIndicator from "common/PendingChangesIndicator"; import MeasurementSummary from "component/MeasurementSummary"; -import { useComponentAPI, useSnackbar, useThemeType } from "hooks"; +import { useComponentAPI, usePendingChanges, useSnackbar, useThemeType } from "hooks"; import InteractionsOverview from "interactions/InteractionsOverview"; import { cloneDeep } from "lodash"; import { Component, Device, Interaction } from "models"; +import moment from "moment"; import { getFriendlyAddressTypeName, getHumanReadableAddress } from "pbHelpers/AddressType"; import { controllerModeLabel } from "pbHelpers/Component"; import { @@ -121,6 +123,9 @@ export default function ComponentCard(props: Props) { const location = useLocation() const [sensors, setSensors] = useState([]); const [{ user, showErrors, as }] = useGlobalState(); + const { pending: pendingChanges, accepted: changesAccepted } = usePendingChanges( + component.status.synced + ); const updateControllerState = (checked: boolean) => { let updatedComponent = cloneDeep(component); @@ -133,6 +138,8 @@ export default function ComponentCard(props: Props) { .update(device.id(), updatedComponent.settings, undefined, undefined, as) .then(() => { success(component.name() + "'s mode was set to " + describe); + updatedComponent.status.synced = false; + updatedComponent.status.lastUpdate = moment().toISOString(); refreshCallback(updatedComponent); }) .catch(() => { @@ -287,19 +294,34 @@ export default function ComponentCard(props: Props) { className={classes.cardHeader} titleTypographyProps={{ variant: "subtitle1" }} subheader={ - // !newStructure ? ( - // - // ) : ( + + {(pendingChanges || changesAccepted) && ( + + + + )} + {/* !newStructure ? ( + + ) : ( */} - // ) + {/* ) */} + } action={ diff --git a/src/device/DeviceOverview.tsx b/src/device/DeviceOverview.tsx index 0115568..ac20e89 100644 --- a/src/device/DeviceOverview.tsx +++ b/src/device/DeviceOverview.tsx @@ -1,9 +1,9 @@ import { Chip, Grid2 as Grid, Theme, Tooltip, Skeleton } from "@mui/material"; import { DataUsage, SimCard, Launch, DeviceHub } from "@mui/icons-material"; -import StatusChip from "common/StatusChip"; +import PendingChangesChip from "common/PendingChangesChip"; import DeviceTags from "device/DeviceTags"; import VersionChip from "device/VersionChip"; -import { useSnackbar, useWidth, usePrevious } from "hooks"; +import { useSnackbar, useWidth, usePendingChanges, usePrevious } from "hooks"; import { Device, latestFirmwareVersion, Component } from "models"; import moment from "moment"; import { describeConnectivity } from "pbHelpers/Connectivity"; @@ -54,6 +54,10 @@ export default function DeviceOverview(props: Props) { // const match = useRouteMatch(); const [powerComponent, setPowerComponent] = useState(); const [modemComponent, setModemComponent] = useState(); + const { pending: pendingChanges, accepted: changesAccepted } = usePendingChanges( + device.status.synced, + !loading && device.id() > 0 + ); // const [hologramDialog, setHologramDialog] = useState(false); useEffect(() => { @@ -228,9 +232,9 @@ export default function DeviceOverview(props: Props) { )} - {!device.status.synced && ( + {(pendingChanges || changesAccepted) && ( - + )} {device.settings.needsBusControl && ( diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 0bd5baf..e93b9d5 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -26,6 +26,7 @@ export { export * from "./useDebounce"; // export * from "./useForceUpdate"; // export * from "./useInterval"; +export * from "./usePendingChanges"; export * from "./usePrevious"; export * from "./useThemeType"; export * from "./useWidth"; diff --git a/src/hooks/usePendingChanges.ts b/src/hooks/usePendingChanges.ts new file mode 100644 index 0000000..5852adb --- /dev/null +++ b/src/hooks/usePendingChanges.ts @@ -0,0 +1,31 @@ +import { useEffect, useRef, useState } from "react"; + +/** + * Tracks a synced flag and reports whether changes are pending or were + * accepted while mounted (a false -> true transition, e.g. from a websocket + * status update). Pass enabled=false until real data has loaded so the + * initial placeholder -> loaded flip is not mistaken for an accepted change. + */ +export function usePendingChanges(synced: boolean, enabled: boolean = true) { + const prevSynced = useRef(undefined); + const [accepted, setAccepted] = useState(false); + + useEffect(() => { + if (!enabled) { + prevSynced.current = undefined; + setAccepted(false); + return; + } + if (prevSynced.current === false && synced) { + setAccepted(true); + } else if (!synced) { + setAccepted(false); + } + prevSynced.current = synced; + }, [synced, enabled]); + + return { + pending: enabled && !synced, + accepted: enabled && synced && accepted + }; +} diff --git a/src/interactions/InteractionsOverview.tsx b/src/interactions/InteractionsOverview.tsx index 450c44f..68757cb 100644 --- a/src/interactions/InteractionsOverview.tsx +++ b/src/interactions/InteractionsOverview.tsx @@ -4,7 +4,6 @@ import { CardActions, CardContent, CardHeader, - CircularProgress, darken, Grid2 as Grid, IconButton, @@ -15,7 +14,8 @@ import { Tooltip, Typography } from "@mui/material"; -import { CheckCircleOutline, Settings } from "@mui/icons-material"; +import { Settings } from "@mui/icons-material"; +import PendingChangesIndicator from "common/PendingChangesIndicator"; import { useInteractionsAPI, usePrevious, useSnackbar } from "hooks"; import { cloneDeep } from "lodash"; import { Component, Device, Interaction } from "models"; @@ -329,17 +329,11 @@ export default function InteractionsOverview(props: Props) { title={interactionResultText(interaction, sink)} subheader={ statusText ? ( - - {isPending && } - {isAccepted && ( - - )} - - {statusText} - - + ) : ""} subheaderTypographyProps={{ variant: "caption" }} action={action} diff --git a/src/pages/Device.tsx b/src/pages/Device.tsx index 733d621..801c1f1 100644 --- a/src/pages/Device.tsx +++ b/src/pages/Device.tsx @@ -19,7 +19,7 @@ import { DeviceAvailabilityMap, FindAvailablePositions, OffsetAvailabilityMap } import ComponentCard from "component/ComponentCard"; import { sameComponentID, sortComponents } from "pbHelpers/Component"; import { isController } from "pbHelpers/ComponentType"; -import { or } from "utils"; +import { getTimestampMillis, isStaleStatusUpdate, or } from "utils"; import ComponentDiagnostics from "component/ComponentDiagnostics"; import DeviceWizard from "device/DeviceWizard"; import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents"; @@ -27,12 +27,6 @@ import { useWebSocket } from "hooks/useWebSocket"; type TimestampedReading = { timestamp?: string }; -function getTimestampMillis(timestamp?: string): number | undefined { - if (!timestamp) return undefined; - const parsed = Date.parse(timestamp); - return Number.isNaN(parsed) ? undefined : parsed; -} - function pickNewestReading( current: T | null | undefined, incoming: T | null | undefined @@ -270,6 +264,10 @@ export default function DevicePage() { if (!data) return; const { key, component } = data; setComponents((prev) => { + const existing = prev.get(key); + if (existing && isStaleStatusUpdate(existing.status, component.status)) { + return prev; + } const updated = new Map(prev); updated.set(key, component); return updated; @@ -315,17 +313,7 @@ export default function DevicePage() { 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)) { + if (isStaleStatusUpdate(existing.status, interaction.status)) { return prev; } const updated = [...prev]; diff --git a/src/pages/DeviceComponent.tsx b/src/pages/DeviceComponent.tsx index a245e51..e6ab9fa 100644 --- a/src/pages/DeviceComponent.tsx +++ b/src/pages/DeviceComponent.tsx @@ -4,7 +4,7 @@ import { NextMeasurementChip } from "common/NextMeasurementChip"; import ResponsiveTable, { Column } from "common/ResponsiveTable"; // import { getTableIcons } from "common/ResponsiveTable"; import SmartBreadcrumb from "common/SmartBreadcrumb"; -import StatusChip from "common/StatusChip"; +import PendingChangesChip from "common/PendingChangesChip"; import { GetDefaultDateRange } from "common/time/DateRange"; import ComponentActions from "component/ComponentActions"; import ComponentChart from "component/ComponentChart"; @@ -16,11 +16,14 @@ import { useComponentAPI, useDeviceAPI, useGroupAPI, + useHTTP, useInteractionsAPI, + usePendingChanges, usePrevious, useSnackbar, useUserAPI } from "hooks"; +import { useWebSocket } from "hooks/useWebSocket"; import InteractionChip from "interactions/InteractionChip"; import InteractionSettings from "interactions/InteractionSettings"; import { cloneDeep } from "lodash"; @@ -40,9 +43,10 @@ import { getDefaultInteraction } from "pbHelpers/Interaction"; import { pond } from "protobuf-ts/pond"; import { useGlobalState } from "providers"; import { useTeamAPI } from "providers/pond/teamAPI"; -import { useCallback, useEffect, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useParams } from "react-router-dom"; // import { useRouteMatch } from "react-router"; +import { isStaleStatusUpdate } from "utils/syncStatus"; import { or } from "utils/types"; const useStyles = makeStyles((theme: Theme) => { @@ -142,6 +146,55 @@ export default function DeviceComponent() { const [deviceComponentPrefs, setDeviceComponentPrefs] = useState< pond.DeviceComponentPreferences >(); + const { token } = useHTTP(); + const componentPending = usePendingChanges( + component.status.synced, + !loadingInitial && component.key() !== "" + ); + + const liveContextKeys = useMemo(() => getContextKeys(), []); + const liveContextTypes = useMemo(() => getContextTypes(), []); + /** Matches the REST calls: omit ?as= when first context type is "team". */ + const liveAs = + as && !(liveContextTypes.length > 0 && liveContextTypes[0] === "team") + ? as + : undefined; + + // --- Real-time component updates --- + // Streams component changes for this device, including status changes + // when the device accepts pending component updates. + useWebSocket<{ key: string; component: Component } | null>({ + path: `/v1/live/devices/${deviceID}/components`, + parse: (e) => { + try { + const raw = JSON.parse(e.data); + const comp = Component.any(raw); + if (!comp?.settings) { + console.warn("[ws] received component without settings:", raw); + return null; + } + return { key: comp.key(), component: comp }; + } catch (err) { + console.warn("[ws] failed to parse component:", err); + return null; + } + }, + onMessage: (data) => { + if (!data || data.key !== componentID) return; + setComponent((prev) => { + if (prev.key() !== data.key) return prev; + if (isStaleStatusUpdate(prev.status, data.component.status)) { + return prev; + } + return data.component; + }); + }, + keys: liveContextKeys, + types: liveContextTypes, + as: liveAs, + token, + enabled: !!deviceID && componentID !== "", + }); const setDefaultState = () => { setDevice(Device.create()); @@ -389,9 +442,12 @@ export default function DeviceComponent() { alignItems="center" wrap="nowrap" className={classes.overviewContainer}> - {!component.status.synced && ( + {(componentPending.pending || componentPending.accepted) && ( - + )} diff --git a/src/pbHelpers/Status.tsx b/src/pbHelpers/Status.tsx deleted file mode 100644 index 54d6f70..0000000 --- a/src/pbHelpers/Status.tsx +++ /dev/null @@ -1,67 +0,0 @@ -import { cyan } from "@mui/material/colors"; -import AcceptedIcon from "@mui/icons-material/CloudDone"; -import PendingIcon from "@mui/icons-material/CloudQueueTwoTone"; -import RejectedIcon from "@mui/icons-material/SmsFailed"; - -export interface StatusHelper { - description: string; - icon: any; -} - -const Unknown: StatusHelper = { - description: "Status unknown", - icon: null -}; - -const Pending: StatusHelper = { - description: "Pending changes", - icon: -}; - -const Stale: StatusHelper = { - description: "Stale update", - icon: null -}; - -const Accepted: StatusHelper = { - description: "Settings synced", - icon: -}; - -const Rejected: StatusHelper = { - description: "Update failed", - icon: -}; - -const Received: StatusHelper = { - description: "Settings synced", - icon: -}; - -const STATUS_MAP = new Map([ - ["pending", Pending], - ["stale", Stale], - ["accepted", Accepted], - ["rejected", Rejected], - ["received", Received] -]); - -export function getStatusHelper(status: string): StatusHelper { - const statuses = Array.from(STATUS_MAP.keys()); - for (var i = 0; i < statuses.length; i++) { - let key = statuses[i]; - if (status === key) { - return STATUS_MAP.get(key) as StatusHelper; - } - } - - return Unknown; -} - -export function getStatusDescription(status: string): string { - return getStatusHelper(status).description; -} - -export function getStatusIcon(status: string): any { - return getStatusHelper(status).icon; -} diff --git a/src/utils/index.ts b/src/utils/index.ts index 5a5ef3f..29ff37c 100644 --- a/src/utils/index.ts +++ b/src/utils/index.ts @@ -2,5 +2,6 @@ export * from "./download"; export * from "./environment"; export * from "./numbers"; export * from "./strings"; +export * from "./syncStatus"; export * from "./types"; export * from "./units"; diff --git a/src/utils/syncStatus.ts b/src/utils/syncStatus.ts new file mode 100644 index 0000000..107c2a8 --- /dev/null +++ b/src/utils/syncStatus.ts @@ -0,0 +1,41 @@ +export function getTimestampMillis(timestamp?: string): number | undefined { + if (!timestamp) return undefined; + const parsed = Date.parse(timestamp); + return Number.isNaN(parsed) ? undefined : parsed; +} + +/** The sync-tracking fields shared by ComponentStatus and InteractionStatus. */ +export interface SyncStatusLike { + synced: boolean; + lastUpdate?: string; + lastSynced?: string; +} + +/** + * Decides whether an incoming websocket status message is stale and should + * not replace the status we already hold. A message is stale when it was + * generated before our latest local update, including a synced echo that + * predates a pending change we just submitted. + */ +export function isStaleStatusUpdate(existing: SyncStatusLike, incoming: SyncStatusLike): boolean { + const existingLastUpdate = getTimestampMillis(existing.lastUpdate); + const incomingLastUpdate = getTimestampMillis(incoming.lastUpdate); + const incomingLastSynced = getTimestampMillis(incoming.lastSynced); + if ( + existingLastUpdate !== undefined && + incomingLastUpdate !== undefined && + existingLastUpdate > incomingLastUpdate + ) { + return true; + } + if ( + !existing.synced && + incoming.synced && + incomingLastUpdate !== undefined && + existingLastUpdate === incomingLastUpdate && + (incomingLastSynced === undefined || incomingLastSynced < incomingLastUpdate) + ) { + return true; + } + return false; +}