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;
+}