Miscellaneous fixes (#23619)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / AMD64 Extra Build (push) Blocked by required conditions
CI / ARM Extra Build (push) Blocked by required conditions
CI / Synaptics Build (push) Blocked by required conditions
CI / Assemble and push default build (push) Blocked by required conditions
CI / Jetson Jetpack 6 (push) Waiting to run

* fix stale active object indicators on the live dashboard

The camera_activity/<camera> snapshot cache is only written when a client sends onConnect, and object "end" events only update the local state of mounted useCameraActivity hooks, never the cache. As a result, a hook that seeded from a stale cache or missed an "end" event while disconnected showed objects that had already left, with no path to correct itself short of a full page reload.

This change will re-request the snapshot on hook mount (collapsed to one onConnect per task across camera cards), and always re-notify camera_activity topics so hooks reconcile against their own local state instead of relying on snapshot-vs-snapshot comparison, and clear the payload dedup cache on reconnect and resync so byte-identical snapshots still apply.

* docs tweaks

* fix mqtt log message

* use consistent values for lpr debug frame filenames

with millisecond resolution

* apply object events through a functional updater to prevent lost updates

The events effect derived a new objects list from the value captured at render time and wrote the whole list back. When events arrived close
together, a run derived from a stale list erased a concurrent run's removal; the resurrected object then had no remaining "end" event to clear it, and the add branch could mint a duplicate entry that no splice could ever remove, leaving the live dashboard showing active objects the backend had already cleared, until a page reload.

The fix is to apply each event inside setObjects so it operates on the true current list exactly once. Unchanged results return the same reference so React bails out of re-rendering, and the label rewrite is hoisted so added objects get the sub_label/verified label directly instead of relying on the effect re-running against its own state update.
This commit is contained in:
Josh Hawkins
2026-07-04 00:25:22 -06:00
committed by GitHub
parent c007661a71
commit 729ee86043
10 changed files with 209 additions and 119 deletions
+8 -1
View File
@@ -2,7 +2,11 @@ import { baseUrl } from "./baseUrl";
import { ReactNode, useCallback, useEffect, useRef } from "react";
import { WsSendContext } from "./wsContext";
import type { Update } from "./wsContext";
import { processWsMessage, resetWsStore } from "./ws";
import {
invalidateCameraActivityCache,
processWsMessage,
resetWsStore,
} from "./ws";
export function WsProvider({ children }: { children: ReactNode }) {
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
@@ -34,6 +38,9 @@ export function WsProvider({ children }: { children: ReactNode }) {
ws.onopen = () => {
reconnectAttempt.current = 0;
// events may have been missed while disconnected — the snapshot
// requested below must fully apply even if byte-identical
invalidateCameraActivityCache();
ws.send(
JSON.stringify({ topic: "onConnect", message: "", retain: false }),
);
+28 -5
View File
@@ -82,12 +82,15 @@ export function processWsMessage(raw: string) {
function applyTopicUpdate(topic: string, newVal: unknown) {
const oldVal = wsState[topic];
// camera_activity snapshots always re-notify: consumers reconcile local
// state that may have diverged from an unchanged snapshot
const isActivitySnapshot = topic.startsWith("camera_activity/");
// Fast path: === for primitives ("ON"/"OFF", numbers).
// Fall back to isEqual for objects/arrays.
const unchanged =
oldVal === newVal ||
(typeof newVal === "object" && newVal !== null && isEqual(oldVal, newVal));
if (unchanged) return;
if (unchanged && !isActivitySnapshot) return;
wsState[topic] = newVal;
// Snapshot the Set — a listener may trigger unmount that modifies it.
@@ -131,6 +134,25 @@ let wsMessageIdCounter = 0;
// traversals) on every flush — critical with many cameras.
let lastCameraActivityPayload: string | null = null;
// Make the next camera_activity snapshot fully apply even when byte-identical
// to the previous one — local state may have diverged while no messages flowed
export function invalidateCameraActivityCache() {
lastCameraActivityPayload = null;
}
// Collapse same-task resync requests (one hook per camera card) into a
// single onConnect round-trip
let resyncScheduled = false;
function requestCameraActivityResync(sendOnConnect: () => void) {
if (resyncScheduled) return;
resyncScheduled = true;
queueMicrotask(() => {
resyncScheduled = false;
invalidateCameraActivityCache();
sendOnConnect();
});
}
function applyCameraActivity(payload: string) {
// Fast path: if the raw JSON string is identical, nothing changed.
if (payload === lastCameraActivityPayload) return;
@@ -509,15 +531,16 @@ export function useInitialCameraState(
// camera_activity sub-topic payload is already parsed by expandCameraActivity
const data = payload as FrigateCameraState | undefined;
// onConnect is sent once in WsProvider.onopen — no need to re-request on
// every component mount. Components read cached wsState immediately via
// useSyncExternalStore. Only re-request when the user tabs back in.
// the cached snapshot is only written on onConnect and can be stale by the
// time this hook mounts — re-request on mount and when the user tabs back in
useEffect(() => {
if (!revalidateOnFocus) return;
requestCameraActivityResync(() => sendCommand("onConnect"));
const listener = () => {
if (document.visibilityState === "visible") {
sendCommand("onConnect");
requestCameraActivityResync(() => sendCommand("onConnect"));
}
};
addEventListener("visibilitychange", listener);
+62 -47
View File
@@ -7,7 +7,7 @@ import {
} from "@/api/ws";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { MotionData, ReviewSegment } from "@/types/review";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { AudioDetection, ObjectType } from "@/types/ws";
import { useTimelineUtils } from "./use-timeline-utils";
import useDeepMemo from "./use-deep-memo";
@@ -57,7 +57,13 @@ export function useCameraActivity(
);
useEffect(() => {
if (updatedCameraState) {
setObjects(updatedCameraState.objects);
// functional updater keeps `objects` out of the deps: this effect must
// only run on snapshot arrival, or it would re-assert a stale snapshot
// over newer event-driven state
const newObjects = updatedCameraState.objects ?? [];
setObjects((currentObjects) =>
isEqual(currentObjects, newObjects) ? currentObjects : newObjects,
);
}
}, [updatedCameraState, camera]);
@@ -82,15 +88,6 @@ export function useCameraActivity(
const { payload: event } = useFrigateEvents();
const updatedEvent = useDeepMemo(event);
const handleSetObjects = useCallback(
(newObjects: ObjectType[]) => {
if (!isEqual(objects, newObjects)) {
setObjects(newObjects);
}
},
[objects],
);
useEffect(() => {
if (!updatedEvent) {
return;
@@ -100,53 +97,71 @@ export function useCameraActivity(
return;
}
const updatedEventIndex =
objects?.findIndex((obj) => obj.id === updatedEvent.after.id) ?? -1;
// functional updater keeps `objects` out of the deps: each event applies
// exactly once against the true current list, so events arriving close
// together cannot clobber each other's changes with a stale write-back
setObjects((currentObjects) => {
const existingObjects = currentObjects ?? [];
const updatedEventIndex = existingObjects.findIndex(
(obj) => obj.id === updatedEvent.after.id,
);
let newObjects: ObjectType[] = [...(objects ?? [])];
if (updatedEvent.type === "end") {
if (updatedEventIndex !== -1) {
if (updatedEvent.type === "end") {
if (updatedEventIndex === -1) {
return currentObjects;
}
const newObjects = [...existingObjects];
newObjects.splice(updatedEventIndex, 1);
return newObjects;
}
} else {
let label = updatedEvent.after.label;
if (updatedEvent.after.sub_label) {
const sub_label = updatedEvent.after.sub_label[0];
if (attributeLabels.includes(sub_label)) {
label = sub_label;
} else {
label = `${label}-verified`;
}
}
if (updatedEventIndex === -1) {
// add unknown updatedEvent to list if not stationary
if (!updatedEvent.after.stationary) {
const newActiveObject: ObjectType = {
id: updatedEvent.after.id,
label: updatedEvent.after.label,
stationary: updatedEvent.after.stationary,
area: updatedEvent.after.area,
ratio: updatedEvent.after.ratio,
score: updatedEvent.after.score,
sub_label: updatedEvent.after.sub_label?.[0] ?? "",
};
newObjects = [...(objects ?? []), newActiveObject];
if (updatedEvent.after.stationary) {
return currentObjects;
}
} else {
let label = updatedEvent.after.label;
if (updatedEvent.after.sub_label) {
const sub_label = updatedEvent.after.sub_label[0];
if (attributeLabels.includes(sub_label)) {
label = sub_label;
} else {
label = `${label}-verified`;
}
}
newObjects[updatedEventIndex] = {
...newObjects[updatedEventIndex],
const newActiveObject: ObjectType = {
id: updatedEvent.after.id,
label,
stationary: updatedEvent.after.stationary,
area: updatedEvent.after.area,
ratio: updatedEvent.after.ratio,
score: updatedEvent.after.score,
sub_label: updatedEvent.after.sub_label?.[0] ?? "",
};
return [...existingObjects, newActiveObject];
}
}
handleSetObjects(newObjects);
}, [attributeLabels, camera, updatedEvent, objects, handleSetObjects]);
const existing = existingObjects[updatedEventIndex];
if (
existing.label === label &&
existing.stationary === updatedEvent.after.stationary
) {
return currentObjects;
}
const newObjects = [...existingObjects];
newObjects[updatedEventIndex] = {
...existing,
label,
stationary: updatedEvent.after.stationary,
};
return newObjects;
});
}, [attributeLabels, camera, updatedEvent]);
// determine if camera is offline