Debug replay (#22212)
CI / AMD64 Build (push) Waiting to run
CI / ARM Build (push) Waiting to run
CI / Jetson Jetpack 6 (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

* debug replay implementation

* fix masks after dev rebase

* fix squash merge issues

* fix

* fix

* fix

* no need to write debug replay camera to config

* camera and filter button and dropdown

* add filters

* add ability to edit motion and object config for debug replay

* add debug draw overlay to debug replay

* add guard to prevent crash when camera is no longer in camera_states

* fix overflow due to radix absolutely positioned elements

* increase number of messages

* ensure deep_merge replaces existing list values when override is true

* add back button

* add debug replay to explore and review menus

* clean up

* clean up

* update instructions to prevent exposing exception info

* fix typing

* refactor output logic

* refactor with helper function

* move init to function for consistency
This commit is contained in:
Josh Hawkins
2026-03-04 10:07:34 -06:00
committed by GitHub
parent 5e7d426768
commit 95956a690b
68 changed files with 4572 additions and 519 deletions
+2
View File
@@ -117,6 +117,7 @@
"button": {
"add": "Add",
"apply": "Apply",
"applying": "Applying…",
"reset": "Reset",
"undo": "Undo",
"done": "Done",
@@ -252,6 +253,7 @@
"review": "Review",
"explore": "Explore",
"export": "Export",
"actions": "Actions",
"uiPlayground": "UI Playground",
"faceLibrary": "Face Library",
"classification": "Classification",
+4
View File
@@ -216,6 +216,10 @@
},
"hideObjectDetails": {
"label": "Hide object path"
},
"debugReplay": {
"label": "Debug replay",
"aria": "View this tracked object in the debug replay view"
}
},
"dialog": {
+54
View File
@@ -0,0 +1,54 @@
{
"title": "Debug Replay",
"description": "Replay camera recordings for debugging. The object list shows a time-delayed summary of detected objects and the Messages tab shows a stream of Frigate's internal messages from the replay footage.",
"websocket_messages": "Messages",
"dialog": {
"title": "Start Debug Replay",
"description": "Create a temporary replay camera that loops historical footage for debugging object detection and tracking issues. The replay camera will have the same detection configuration as the source camera. Choose a time range to begin.",
"camera": "Source Camera",
"timeRange": "Time Range",
"preset": {
"1m": "Last 1 Minute",
"5m": "Last 5 Minutes",
"timeline": "From Timeline",
"custom": "Custom"
},
"startButton": "Start Replay",
"selectFromTimeline": "Select",
"starting": "Starting replay...",
"startLabel": "Start",
"endLabel": "End",
"toast": {
"success": "Debug replay started successfully",
"error": "Failed to start debug replay: {{error}}",
"alreadyActive": "A replay session is already active",
"stopped": "Debug replay stopped",
"stopError": "Failed to stop debug replay: {{error}}",
"goToReplay": "Go to Replay"
}
},
"page": {
"noSession": "No Active Replay Session",
"noSessionDesc": "Start a debug replay from the History view by clicking the Debug Replay button in the toolbar.",
"goToRecordings": "Go to History",
"sourceCamera": "Source Camera",
"replayCamera": "Replay Camera",
"initializingReplay": "Initializing replay...",
"stoppingReplay": "Stopping replay...",
"stopReplay": "Stop Replay",
"confirmStop": {
"title": "Stop Debug Replay?",
"description": "This will stop the replay session and clean up all temporary data. Are you sure?",
"confirm": "Stop Replay",
"cancel": "Cancel"
},
"activity": "Activity",
"objects": "Object List",
"audioDetections": "Audio Detections",
"noActivity": "No activity detected",
"activeTracking": "Active tracking",
"noActiveTracking": "No active tracking",
"configuration": "Configuration",
"configurationDesc": "Fine tune motion detection and object tracking settings for the debug replay camera. No changes are saved to your Frigate configuration file."
}
}
@@ -1392,6 +1392,7 @@
},
"toast": {
"success": "Settings saved successfully",
"applied": "Settings applied successfully",
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
"error": "Failed to save settings",
"validationError": "Validation failed: {{message}}",
+30 -2
View File
@@ -7,12 +7,39 @@
"logs": {
"frigate": "Frigate Logs - Frigate",
"go2rtc": "Go2RTC Logs - Frigate",
"nginx": "Nginx Logs - Frigate"
"nginx": "Nginx Logs - Frigate",
"websocket": "Messages Logs - Frigate"
}
},
"title": "System",
"metrics": "System metrics",
"logs": {
"websocket": {
"label": "Messages",
"pause": "Pause",
"resume": "Resume",
"clear": "Clear",
"filter": {
"all": "All topics",
"topics": "Topics",
"events": "Events",
"reviews": "Reviews",
"classification": "Classification",
"face_recognition": "Face Recognition",
"lpr": "LPR",
"camera_activity": "Camera activity",
"system": "System",
"camera": "Camera",
"all_cameras": "All cameras",
"cameras_count_one": "{{count}} Camera",
"cameras_count_other": "{{count}} Cameras"
},
"empty": "No messages captured yet",
"count": "{{count}} messages",
"expanded": {
"payload": "Payload"
}
},
"download": {
"label": "Download Logs"
},
@@ -189,7 +216,8 @@
"cameraIsOffline": "{{camera}} is offline",
"detectIsSlow": "{{detect}} is slow ({{speed}} ms)",
"detectIsVerySlow": "{{detect}} is very slow ({{speed}} ms)",
"shmTooLow": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB."
"shmTooLow": "/dev/shm allocation ({{total}} MB) should be increased to at least {{min}} MB.",
"debugReplayActive": "Debug replay session is active"
},
"enrichments": {
"title": "Enrichments",
+3 -1
View File
@@ -30,6 +30,7 @@ const Classification = lazy(() => import("@/pages/ClassificationModel"));
const Chat = lazy(() => import("@/pages/Chat"));
const Logs = lazy(() => import("@/pages/Logs"));
const AccessDenied = lazy(() => import("@/pages/AccessDenied"));
const Replay = lazy(() => import("@/pages/Replay"));
function App() {
const { data: config } = useSWR<FrigateConfig>("config", {
@@ -108,7 +109,8 @@ function DefaultAppView() {
<Route path="/faces" element={<FaceLibrary />} />
<Route path="/classification" element={<Classification />} />
<Route path="/chat" element={<Chat />} />
<Route path="/playground" element={<UIPlayground />} />
<Route path="/playground" element={<UIPlayground />} />{" "}
<Route path="/replay" element={<Replay />} />{" "}
</Route>
<Route path="/unauthorized" element={<AccessDenied />} />
<Route path="*" element={<Redirect to="/" />} />
+49 -4
View File
@@ -1,5 +1,5 @@
import { baseUrl } from "./baseUrl";
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import useWebSocket, { ReadyState } from "react-use-websocket";
import {
EmbeddingsReindexProgressType,
@@ -17,6 +17,13 @@ import { FrigateStats } from "@/types/stats";
import { createContainer } from "react-tracked";
import useDeepMemo from "@/hooks/use-deep-memo";
export type WsFeedMessage = {
topic: string;
payload: unknown;
timestamp: number;
id: string;
};
type Update = {
topic: string;
payload: unknown;
@@ -29,6 +36,9 @@ type WsState = {
type useValueReturn = [WsState, (update: Update) => void];
const wsMessageSubscribers = new Set<(msg: WsFeedMessage) => void>();
let wsMessageIdCounter = 0;
function useValue(): useValueReturn {
const wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`;
@@ -43,8 +53,13 @@ function useValue(): useValueReturn {
return;
}
const cameraActivity: { [key: string]: FrigateCameraState } =
JSON.parse(activityValue);
let cameraActivity: { [key: string]: Partial<FrigateCameraState> };
try {
cameraActivity = JSON.parse(activityValue);
} catch {
return;
}
if (Object.keys(cameraActivity).length === 0) {
return;
@@ -53,6 +68,12 @@ function useValue(): useValueReturn {
const cameraStates: WsState = {};
Object.entries(cameraActivity).forEach(([name, state]) => {
const cameraConfig = state?.config;
if (!cameraConfig) {
return;
}
const {
record,
detect,
@@ -67,7 +88,7 @@ function useValue(): useValueReturn {
detections,
object_descriptions,
review_descriptions,
} = state["config"];
} = cameraConfig;
cameraStates[`${name}/recordings/state`] = record ? "ON" : "OFF";
cameraStates[`${name}/enabled/state`] = enabled ? "ON" : "OFF";
cameraStates[`${name}/detect/state`] = detect ? "ON" : "OFF";
@@ -115,6 +136,17 @@ function useValue(): useValueReturn {
...prevState,
[data.topic]: data.payload,
}));
// Notify feed subscribers
if (wsMessageSubscribers.size > 0) {
const feedMsg: WsFeedMessage = {
topic: data.topic,
payload: data.payload,
timestamp: Date.now(),
id: String(wsMessageIdCounter++),
};
wsMessageSubscribers.forEach((cb) => cb(feedMsg));
}
}
},
onOpen: () => {
@@ -740,3 +772,16 @@ export function useJobStatus(
return { payload: currentJob as Job | null };
}
export function useWsMessageSubscribe(callback: (msg: WsFeedMessage) => void) {
const callbackRef = useRef(callback);
callbackRef.current = callback;
useEffect(() => {
const handler = (msg: WsFeedMessage) => callbackRef.current(msg);
wsMessageSubscribers.add(handler);
return () => {
wsMessageSubscribers.delete(handler);
};
}, []);
}
+5 -4
View File
@@ -26,7 +26,8 @@ export default function CameraImage({
const containerRef = useRef<HTMLDivElement | null>(null);
const imgRef = useRef<HTMLImageElement | null>(null);
const { name } = config ? config.cameras[camera] : "";
const cameraConfig = config?.cameras?.[camera];
const { name } = cameraConfig ?? { name: camera };
const { payload: enabledState } = useEnabledState(camera);
const enabled = enabledState ? enabledState === "ON" : true;
@@ -34,15 +35,15 @@ export default function CameraImage({
useResizeObserver(containerRef);
const requestHeight = useMemo(() => {
if (!config || containerHeight == 0) {
if (!cameraConfig || containerHeight == 0) {
return 360;
}
return Math.min(
config.cameras[camera].detect.height,
cameraConfig.detect.height,
Math.round(containerHeight * (isDesktop ? 1.1 : 1.25)),
);
}, [config, camera, containerHeight]);
}, [cameraConfig, containerHeight]);
const [isPortraitImage, setIsPortraitImage] = useState(false);
@@ -44,6 +44,30 @@ const motion: SectionConfigOverrides = {
camera: {
restartRequired: ["frame_height"],
},
replay: {
restartRequired: [],
fieldOrder: [
"threshold",
"contour_area",
"lightning_threshold",
"improve_contrast",
],
fieldGroups: {
sensitivity: ["threshold", "contour_area"],
algorithm: ["improve_contrast"],
},
hiddenFields: [
"enabled",
"enabled_in_config",
"mask",
"raw_mask",
"mqtt_off_delay",
"delta_alpha",
"frame_alpha",
"frame_height",
],
advancedFields: ["lightning_threshold"],
},
};
export default motion;
@@ -99,6 +99,28 @@ const objects: SectionConfigOverrides = {
camera: {
restartRequired: [],
},
replay: {
restartRequired: [],
fieldOrder: ["track", "filters"],
fieldGroups: {
tracking: ["track"],
filtering: ["filters"],
},
hiddenFields: [
"enabled_in_config",
"alert",
"detect",
"mask",
"raw_mask",
"genai",
"genai.enabled_in_config",
"filters.*.mask",
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
],
advancedFields: [],
},
};
export default objects;
@@ -4,4 +4,5 @@ export type SectionConfigOverrides = {
base?: SectionConfig;
global?: Partial<SectionConfig>;
camera?: Partial<SectionConfig>;
replay?: Partial<SectionConfig>;
};
@@ -95,9 +95,9 @@ export interface SectionConfig {
}
export interface BaseSectionProps {
/** Whether this is at global or camera level */
level: "global" | "camera";
/** Camera name (required if level is "camera") */
/** Whether this is at global, camera, or replay level */
level: "global" | "camera" | "replay";
/** Camera name (required if level is "camera" or "replay") */
cameraName?: string;
/** Whether to show override indicator badge */
showOverrideIndicator?: boolean;
@@ -117,6 +117,10 @@ export interface BaseSectionProps {
defaultCollapsed?: boolean;
/** Whether to show the section title (default: false for global, true for camera) */
showTitle?: boolean;
/** If true, apply config in-memory only without writing to YAML */
skipSave?: boolean;
/** If true, buttons are not sticky at the bottom */
noStickyButtons?: boolean;
/** Callback when section status changes */
onStatusChange?: (status: {
hasChanges: boolean;
@@ -156,12 +160,16 @@ export function ConfigSection({
collapsible = false,
defaultCollapsed = true,
showTitle,
skipSave = false,
noStickyButtons = false,
onStatusChange,
pendingDataBySection,
onPendingDataChange,
}: ConfigSectionProps) {
// For replay level, treat as camera-level config access
const effectiveLevel = level === "replay" ? "camera" : level;
const { t, i18n } = useTranslation([
level === "camera" ? "config/cameras" : "config/global",
effectiveLevel === "camera" ? "config/cameras" : "config/global",
"config/cameras",
"views/settings",
"common",
@@ -174,10 +182,10 @@ export function ConfigSection({
// Create a key for this section's pending data
const pendingDataKey = useMemo(
() =>
level === "camera" && cameraName
effectiveLevel === "camera" && cameraName
? `${cameraName}::${sectionPath}`
: sectionPath,
[level, cameraName, sectionPath],
[effectiveLevel, cameraName, sectionPath],
);
// Use pending data from parent if available, otherwise use local state
@@ -222,20 +230,20 @@ export function ConfigSection({
const lastPendingDataKeyRef = useRef<string | null>(null);
const updateTopic =
level === "camera" && cameraName
effectiveLevel === "camera" && cameraName
? cameraUpdateTopicMap[sectionPath]
? `config/cameras/${cameraName}/${cameraUpdateTopicMap[sectionPath]}`
: undefined
: `config/${sectionPath}`;
// Default: show title for camera level (since it might be collapsible), hide for global
const shouldShowTitle = showTitle ?? level === "camera";
const shouldShowTitle = showTitle ?? effectiveLevel === "camera";
// Fetch config
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
// Get section schema using cached hook
const sectionSchema = useSectionSchema(sectionPath, level);
const sectionSchema = useSectionSchema(sectionPath, effectiveLevel);
// Apply special case handling for sections with problematic schema defaults
const modifiedSchema = useMemo(
@@ -247,7 +255,7 @@ export function ConfigSection({
// Get override status
const { isOverridden, globalValue, cameraValue } = useConfigOverride({
config,
cameraName: level === "camera" ? cameraName : undefined,
cameraName: effectiveLevel === "camera" ? cameraName : undefined,
sectionPath,
compareFields: sectionConfig.overrideFields,
});
@@ -256,12 +264,12 @@ export function ConfigSection({
const rawSectionValue = useMemo(() => {
if (!config) return undefined;
if (level === "camera" && cameraName) {
if (effectiveLevel === "camera" && cameraName) {
return get(config.cameras?.[cameraName], sectionPath);
}
return get(config, sectionPath);
}, [config, level, cameraName, sectionPath]);
}, [config, cameraName, sectionPath, effectiveLevel]);
const rawFormData = useMemo(() => {
if (!config) return {};
@@ -328,9 +336,10 @@ export function ConfigSection({
[rawFormData, sanitizeSectionData],
);
// Clear pendingData whenever formData changes (e.g., from server refresh)
// This prevents RJSF's initial onChange call from being treated as a user edit
// Only clear if pendingData is managed locally (not by parent)
// Clear pendingData whenever the section/camera key changes (e.g., switching
// cameras) or when there is no pending data yet (initialization).
// This prevents RJSF's initial onChange call from being treated as a user edit.
// Only clear if pendingData is managed locally (not by parent).
useEffect(() => {
const pendingKeyChanged = lastPendingDataKeyRef.current !== pendingDataKey;
@@ -339,15 +348,16 @@ export function ConfigSection({
isInitializingRef.current = true;
setPendingOverrides(undefined);
setDirtyOverrides(undefined);
// Reset local pending data when switching sections/cameras
if (onPendingDataChange === undefined) {
setPendingData(null);
}
} else if (!pendingData) {
isInitializingRef.current = true;
setPendingOverrides(undefined);
setDirtyOverrides(undefined);
}
if (onPendingDataChange === undefined) {
setPendingData(null);
}
}, [
onPendingDataChange,
pendingData,
@@ -484,7 +494,7 @@ export function ConfigSection({
setIsSaving(true);
try {
const basePath =
level === "camera" && cameraName
effectiveLevel === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
const rawData = sanitizeSectionData(rawFormData);
@@ -495,7 +505,7 @@ export function ConfigSection({
);
const sanitizedOverrides = sanitizeOverridesForSection(
sectionPath,
level,
effectiveLevel,
overrides,
);
@@ -508,16 +518,26 @@ export function ConfigSection({
return;
}
const needsRestart = requiresRestartForOverrides(sanitizedOverrides);
const needsRestart = skipSave
? false
: requiresRestartForOverrides(sanitizedOverrides);
const configData = buildConfigDataForPath(basePath, sanitizedOverrides);
await axios.put("config/set", {
requires_restart: needsRestart ? 1 : 0,
update_topic: updateTopic,
config_data: configData,
...(skipSave ? { skip_save: true } : {}),
});
if (needsRestart) {
if (skipSave) {
toast.success(
t("toast.applied", {
ns: "views/settings",
defaultValue: "Settings applied successfully",
}),
);
} else if (needsRestart) {
statusBar?.addMessage(
"config_restart_required",
t("configForm.restartRequiredFooter", {
@@ -596,7 +616,7 @@ export function ConfigSection({
}, [
sectionPath,
pendingData,
level,
effectiveLevel,
cameraName,
t,
refreshConfig,
@@ -608,15 +628,16 @@ export function ConfigSection({
updateTopic,
setPendingData,
requiresRestartForOverrides,
skipSave,
]);
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
const handleResetToGlobal = useCallback(async () => {
if (level === "camera" && !cameraName) return;
if (effectiveLevel === "camera" && !cameraName) return;
try {
const basePath =
level === "camera" && cameraName
effectiveLevel === "camera" && cameraName
? `cameras.${cameraName}.${sectionPath}`
: sectionPath;
@@ -632,7 +653,7 @@ export function ConfigSection({
t("toast.resetSuccess", {
ns: "views/settings",
defaultValue:
level === "global"
effectiveLevel === "global"
? "Reset to defaults"
: "Reset to global defaults",
}),
@@ -651,7 +672,7 @@ export function ConfigSection({
}
}, [
sectionPath,
level,
effectiveLevel,
cameraName,
requiresRestart,
t,
@@ -661,8 +682,8 @@ export function ConfigSection({
]);
const sectionValidation = useMemo(
() => getSectionValidation({ sectionPath, level, t }),
[sectionPath, level, t],
() => getSectionValidation({ sectionPath, level: effectiveLevel, t }),
[sectionPath, effectiveLevel, t],
);
const customValidate = useMemo(() => {
@@ -733,7 +754,7 @@ export function ConfigSection({
// nested under the section name (e.g., `audio.label`). For global-level
// sections, keys are nested under the section name in `config/global`.
const configNamespace =
level === "camera" ? "config/cameras" : "config/global";
effectiveLevel === "camera" ? "config/cameras" : "config/global";
const title = t(`${sectionPath}.label`, {
ns: configNamespace,
defaultValue: defaultTitle,
@@ -769,7 +790,7 @@ export function ConfigSection({
i18nNamespace={configNamespace}
customValidate={customValidate}
formContext={{
level,
level: effectiveLevel,
cameraName,
globalValue,
cameraValue,
@@ -784,7 +805,7 @@ export function ConfigSection({
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
// For widgets that need access to full camera config (e.g., zone names)
fullCameraConfig:
level === "camera" && cameraName
effectiveLevel === "camera" && cameraName
? config?.cameras?.[cameraName]
: undefined,
fullConfig: config,
@@ -804,7 +825,12 @@ export function ConfigSection({
}}
/>
<div className="sticky bottom-0 z-50 w-full border-t border-secondary bg-background pb-5 pt-0">
<div
className={cn(
"w-full border-t border-secondary bg-background pb-5 pt-0",
!noStickyButtons && "sticky bottom-0 z-50",
)}
>
<div
className={cn(
"flex flex-col items-center gap-4 pt-2 md:flex-row",
@@ -822,15 +848,17 @@ export function ConfigSection({
</div>
)}
<div className="flex w-full items-center gap-2 md:w-auto">
{((level === "camera" && isOverridden) || level === "global") &&
!hasChanges && (
{((effectiveLevel === "camera" && isOverridden) ||
effectiveLevel === "global") &&
!hasChanges &&
!skipSave && (
<Button
onClick={() => setIsResetDialogOpen(true)}
variant="outline"
disabled={isSaving || disabled}
className="flex flex-1 gap-2"
>
{level === "global"
{effectiveLevel === "global"
? t("button.resetToDefault", {
ns: "common",
defaultValue: "Reset to Default",
@@ -862,11 +890,18 @@ export function ConfigSection({
{isSaving ? (
<>
<ActivityIndicator className="h-4 w-4" />
{t("button.saving", {
ns: "common",
defaultValue: "Saving...",
})}
{skipSave
? t("button.applying", {
ns: "common",
defaultValue: "Applying...",
})
: t("button.saving", {
ns: "common",
defaultValue: "Saving...",
})}
</>
) : skipSave ? (
t("button.apply", { ns: "common", defaultValue: "Apply" })
) : (
t("button.save", { ns: "common", defaultValue: "Save" })
)}
@@ -898,7 +933,7 @@ export function ConfigSection({
setIsResetDialogOpen(false);
}}
>
{level === "global"
{effectiveLevel === "global"
? t("button.resetToDefault", { ns: "common" })
: t("button.resetToGlobal", { ns: "common" })}
</AlertDialogAction>
@@ -923,7 +958,7 @@ export function ConfigSection({
)}
<Heading as="h4">{title}</Heading>
{showOverrideIndicator &&
level === "camera" &&
effectiveLevel === "camera" &&
isOverridden && (
<Badge variant="secondary" className="text-xs">
{t("button.overridden", {
@@ -967,7 +1002,7 @@ export function ConfigSection({
<div className="flex items-center gap-3">
<Heading as="h4">{title}</Heading>
{showOverrideIndicator &&
level === "camera" &&
effectiveLevel === "camera" &&
isOverridden && (
<Badge
variant="secondary"
@@ -93,7 +93,7 @@ export function AudioLabelSwitchesWidget(props: WidgetProps) {
getDisplayLabel: getAudioLabelDisplayName,
i18nKey: "audioLabels",
listClassName:
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
enableSearch: true,
}}
/>
@@ -94,7 +94,7 @@ export function ObjectLabelSwitchesWidget(props: WidgetProps) {
getDisplayLabel: getObjectLabelDisplayName,
i18nKey: "objectLabels",
listClassName:
"max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
}}
/>
);
@@ -42,7 +42,7 @@ export function ZoneSwitchesWidget(props: WidgetProps) {
getEntities: getZoneNames,
getDisplayLabel: getZoneDisplayName,
i18nKey: "zoneNames",
listClassName: "max-h-64 overflow-y-auto scrollbar-container",
listClassName: "relative max-h-64 overflow-y-auto scrollbar-container",
}}
/>
);
@@ -1,11 +1,11 @@
import { useState, ReactNode } from "react";
import { useState, ReactNode, useCallback } from "react";
import { SearchResult } from "@/types/search";
import { FrigateConfig } from "@/types/frigateConfig";
import { baseUrl } from "@/api/baseUrl";
import { toast } from "sonner";
import axios from "axios";
import { FiMoreVertical } from "react-icons/fi";
import { buttonVariants } from "@/components/ui/button";
import { Button, buttonVariants } from "@/components/ui/button";
import {
ContextMenu,
ContextMenuContent,
@@ -32,6 +32,7 @@ import useSWR from "swr";
import { Trans, useTranslation } from "react-i18next";
import BlurredIconButton from "../button/BlurredIconButton";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { useNavigate } from "react-router-dom";
type SearchResultActionsProps = {
searchResult: SearchResult;
@@ -52,8 +53,10 @@ export default function SearchResultActions({
isContextMenu = false,
children,
}: SearchResultActionsProps) {
const { t } = useTranslation(["views/explore"]);
const { t } = useTranslation(["views/explore", "views/replay", "common"]);
const isAdmin = useIsAdmin();
const navigate = useNavigate();
const [isStarting, setIsStarting] = useState(false);
const { data: config } = useSWR<FrigateConfig>("config");
@@ -84,6 +87,59 @@ export default function SearchResultActions({
});
};
const handleDebugReplay = useCallback(
(event: SearchResult) => {
setIsStarting(true);
axios
.post("debug_replay/start", {
camera: event.camera,
start_time: event.start_time,
end_time: event.end_time,
})
.then((response) => {
if (response.status === 200) {
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
position: "top-center",
});
navigate("/replay");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
if (error.response?.status === 409) {
toast.error(
t("dialog.toast.alreadyActive", { ns: "views/replay" }),
{
position: "top-center",
closeButton: true,
dismissible: false,
action: (
<a href="/replay" target="_blank" rel="noopener noreferrer">
<Button>
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
</Button>
</a>
),
},
);
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
}
})
.finally(() => {
setIsStarting(false);
});
},
[navigate, t],
);
const MenuItem = isContextMenu ? ContextMenuItem : DropdownMenuItem;
const menuItems = (
@@ -149,6 +205,20 @@ export default function SearchResultActions({
<span>{t("itemMenu.addTrigger.label")}</span>
</MenuItem>
)}
{searchResult.has_clip && (
<MenuItem
className="cursor-pointer"
aria-label={t("itemMenu.debugReplay.aria")}
disabled={isStarting}
onSelect={() => {
handleDebugReplay(searchResult);
}}
>
{isStarting
? t("dialog.starting", { ns: "views/replay" })
: t("itemMenu.debugReplay.label")}
</MenuItem>
)}
{isAdmin && (
<MenuItem
aria-label={t("itemMenu.deleteTrackedObject.label")}
@@ -0,0 +1,46 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "../ui/dropdown-menu";
import { Button } from "../ui/button";
import { useTranslation } from "react-i18next";
import { FaFilm } from "react-icons/fa6";
type ActionsDropdownProps = {
onDebugReplayClick: () => void;
onExportClick: () => void;
};
export default function ActionsDropdown({
onDebugReplayClick,
onExportClick,
}: ActionsDropdownProps) {
const { t } = useTranslation(["components/dialog", "views/replay", "common"]);
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
className="flex items-center gap-2"
aria-label={t("menu.actions", { ns: "common" })}
size="sm"
>
<FaFilm className="size-5 text-secondary-foreground" />
<div className="text-primary">
{t("menu.actions", { ns: "common" })}
</div>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={onExportClick}>
{t("menu.export", { ns: "common" })}
</DropdownMenuItem>
<DropdownMenuItem onClick={onDebugReplayClick}>
{t("title", { ns: "views/replay" })}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}
@@ -0,0 +1,240 @@
import { useMemo, useState } from "react";
import { Button } from "../ui/button";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { SelectSeparator } from "../ui/select";
import { TimeRange } from "@/types/timeline";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import { getUTCOffset } from "@/utils/dateUtil";
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
import { FaArrowRight, FaCalendarAlt } from "react-icons/fa";
import { isDesktop, isIOS } from "react-device-detect";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { useTranslation } from "react-i18next";
type CustomTimeSelectorProps = {
latestTime: number;
range?: TimeRange;
setRange: (range: TimeRange | undefined) => void;
startLabel: string;
endLabel: string;
};
export function CustomTimeSelector({
latestTime,
range,
setRange,
startLabel,
endLabel,
}: CustomTimeSelectorProps) {
const { t } = useTranslation(["common"]);
const { data: config } = useSWR<FrigateConfig>("config");
// times
const timezoneOffset = useMemo(
() =>
config?.ui.timezone
? Math.round(getUTCOffset(new Date(), config.ui.timezone))
: undefined,
[config?.ui.timezone],
);
const localTimeOffset = useMemo(
() =>
Math.round(
getUTCOffset(
new Date(),
Intl.DateTimeFormat().resolvedOptions().timeZone,
),
),
[],
);
const startTime = useMemo(() => {
let time = range?.after || latestTime - 3600;
if (timezoneOffset) {
time = time + (timezoneOffset - localTimeOffset) * 60;
}
return time;
}, [range, latestTime, timezoneOffset, localTimeOffset]);
const endTime = useMemo(() => {
let time = range?.before || latestTime;
if (timezoneOffset) {
time = time + (timezoneOffset - localTimeOffset) * 60;
}
return time;
}, [range, latestTime, timezoneOffset, localTimeOffset]);
const formattedStart = useFormattedTimestamp(
startTime,
config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour")
: t("time.formattedTimestamp.12hour"),
);
const formattedEnd = useFormattedTimestamp(
endTime,
config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour")
: t("time.formattedTimestamp.12hour"),
);
const startClock = useMemo(() => {
const date = new Date(startTime * 1000);
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
}, [startTime]);
const endClock = useMemo(() => {
const date = new Date(endTime * 1000);
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
}, [endTime]);
// calendars
const [startOpen, setStartOpen] = useState(false);
const [endOpen, setEndOpen] = useState(false);
return (
<div
className={`mt-3 flex items-center rounded-lg bg-secondary text-secondary-foreground ${isDesktop ? "mx-8 gap-2 px-2" : "pl-2"}`}
>
<FaCalendarAlt />
<div className="flex flex-wrap items-center">
<Popover
open={startOpen}
onOpenChange={(open) => {
if (!open) {
setStartOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label={startLabel}
variant={startOpen ? "select" : "default"}
size="sm"
onClick={() => {
setStartOpen(true);
setEndOpen(false);
}}
>
{formattedStart}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center" disablePortal>
<TimezoneAwareCalendar
timezone={config?.ui.timezone}
selectedDay={new Date(startTime * 1000)}
onSelect={(day) => {
if (!day) {
return;
}
setRange({
before: endTime,
after: day.getTime() / 1000 + 1,
});
}}
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={startClock}
step={isIOS ? "60" : "1"}
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, second] = isIOS
? [...clock.split(":"), "00"]
: clock.split(":");
const start = new Date(startTime * 1000);
start.setHours(
parseInt(hour),
parseInt(minute),
parseInt(second ?? 0),
0,
);
setRange({
before: endTime,
after: start.getTime() / 1000,
});
}}
/>
</PopoverContent>
</Popover>
<FaArrowRight className="size-4 text-primary" />
<Popover
open={endOpen}
onOpenChange={(open) => {
if (!open) {
setEndOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label={endLabel}
variant={endOpen ? "select" : "default"}
size="sm"
onClick={() => {
setEndOpen(true);
setStartOpen(false);
}}
>
{formattedEnd}
</Button>
</PopoverTrigger>
<PopoverContent className="flex flex-col items-center" disablePortal>
<TimezoneAwareCalendar
timezone={config?.ui.timezone}
selectedDay={new Date(endTime * 1000)}
onSelect={(day) => {
if (!day) {
return;
}
setRange({
after: startTime,
before: day.getTime() / 1000,
});
}}
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="endTime"
type="time"
value={endClock}
step={isIOS ? "60" : "1"}
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, second] = isIOS
? [...clock.split(":"), "00"]
: clock.split(":");
const end = new Date(endTime * 1000);
end.setHours(
parseInt(hour),
parseInt(minute),
parseInt(second ?? 0),
0,
);
setRange({
before: end.getTime() / 1000,
after: startTime,
});
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
);
}
@@ -0,0 +1,367 @@
import { useCallback, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "../ui/dialog";
import { Label } from "../ui/label";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Button } from "../ui/button";
import axios from "axios";
import { toast } from "sonner";
import { isDesktop } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { SelectSeparator } from "../ui/select";
import ActivityIndicator from "../indicators/activity-indicator";
import { LuBug, LuPlay, LuX } from "react-icons/lu";
import { ExportMode } from "@/types/filter";
import { TimeRange } from "@/types/timeline";
import { cn } from "@/lib/utils";
import { CustomTimeSelector } from "./CustomTimeSelector";
const REPLAY_TIME_OPTIONS = ["1", "5", "timeline", "custom"] as const;
type ReplayTimeOption = (typeof REPLAY_TIME_OPTIONS)[number];
type DebugReplayContentProps = {
currentTime: number;
latestTime: number;
range?: TimeRange;
selectedOption: ReplayTimeOption;
isStarting: boolean;
onSelectedOptionChange: (option: ReplayTimeOption) => void;
onStart: () => void;
onCancel: () => void;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
};
export function DebugReplayContent({
currentTime,
latestTime,
range,
selectedOption,
isStarting,
onSelectedOptionChange,
onStart,
onCancel,
setRange,
setMode,
}: DebugReplayContentProps) {
const { t } = useTranslation(["views/replay"]);
return (
<div className="w-full">
{isDesktop && (
<>
<DialogHeader>
<DialogTitle>{t("dialog.title")}</DialogTitle>
<DialogDescription>{t("dialog.description")}</DialogDescription>
</DialogHeader>
<SelectSeparator className="my-4 bg-secondary" />
</>
)}
{/* Time range */}
<div className="mt-4 flex flex-col gap-2">
<RadioGroup
className="mt-2 flex flex-col gap-4"
value={selectedOption}
onValueChange={(value) =>
onSelectedOptionChange(value as ReplayTimeOption)
}
>
{REPLAY_TIME_OPTIONS.map((opt) => (
<div key={opt} className="flex items-center gap-2">
<RadioGroupItem
className={
opt === selectedOption
? "bg-selected from-selected/50 to-selected/90 text-selected"
: "bg-secondary from-secondary/50 to-secondary/90 text-secondary"
}
id={`replay-${opt}`}
value={opt}
/>
<Label className="cursor-pointer" htmlFor={`replay-${opt}`}>
{opt === "custom"
? t("dialog.preset.custom")
: opt === "timeline"
? t("dialog.preset.timeline")
: t(`dialog.preset.${opt}m`)}
</Label>
</div>
))}
</RadioGroup>
</div>
{/* Custom time inputs */}
{selectedOption === "custom" && (
<CustomTimeSelector
latestTime={latestTime}
range={range}
setRange={setRange}
startLabel={t("dialog.startLabel")}
endLabel={t("dialog.endLabel")}
/>
)}
{isDesktop && <SelectSeparator className="my-4 bg-secondary" />}
<DialogFooter
className={isDesktop ? "" : "mt-3 flex flex-col-reverse gap-4"}
>
<div
className={`cursor-pointer p-2 text-center ${isDesktop ? "" : "w-full"}`}
onClick={onCancel}
>
{t("button.cancel", { ns: "common" })}
</div>
<Button
className={isDesktop ? "" : "w-full"}
variant="select"
size="sm"
disabled={isStarting}
onClick={() => {
if (selectedOption === "timeline") {
setRange({
after: currentTime - 30,
before: currentTime + 30,
});
setMode("timeline");
} else {
onStart();
}
}}
>
{isStarting ? <ActivityIndicator className="mr-2" /> : null}
{isStarting
? t("dialog.starting")
: selectedOption === "timeline"
? t("dialog.selectFromTimeline")
: t("dialog.startButton")}
</Button>
</DialogFooter>
</div>
);
}
type DebugReplayDialogProps = {
camera: string;
currentTime: number;
latestTime: number;
range?: TimeRange;
mode: ExportMode;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
};
export default function DebugReplayDialog({
camera,
currentTime,
latestTime,
range,
mode,
setRange,
setMode,
}: DebugReplayDialogProps) {
const { t } = useTranslation(["views/replay"]);
const navigate = useNavigate();
const [selectedOption, setSelectedOption] = useState<ReplayTimeOption>("1");
const [isStarting, setIsStarting] = useState(false);
const handleTimeOptionChange = useCallback(
(option: ReplayTimeOption) => {
setSelectedOption(option);
if (option === "custom" || option === "timeline") {
return;
}
const minutes = parseInt(option, 10);
const end = latestTime;
setRange({ after: end - minutes * 60, before: end });
},
[latestTime, setRange],
);
const handleStart = useCallback(() => {
if (!range || range.before <= range.after) {
toast.error(
t("dialog.toast.error", { error: "End time must be after start time" }),
{ position: "top-center" },
);
return;
}
setIsStarting(true);
axios
.post("debug_replay/start", {
camera: camera,
start_time: range.after,
end_time: range.before,
})
.then((response) => {
if (response.status === 200) {
toast.success(t("dialog.toast.success"), {
position: "top-center",
});
setMode("none");
setRange(undefined);
navigate("/replay");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
if (error.response?.status === 409) {
toast.error(t("dialog.toast.alreadyActive"), {
position: "top-center",
closeButton: true,
dismissible: false,
action: (
<a href="/replay" target="_blank" rel="noopener noreferrer">
<Button>{t("dialog.toast.goToReplay")}</Button>
</a>
),
});
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
}
})
.finally(() => {
setIsStarting(false);
});
}, [camera, range, navigate, setMode, setRange, t]);
const handleCancel = useCallback(() => {
setMode("none");
setRange(undefined);
}, [setMode, setRange]);
const Overlay = isDesktop ? Dialog : Drawer;
const Trigger = isDesktop ? DialogTrigger : DrawerTrigger;
const Content = isDesktop ? DialogContent : DrawerContent;
return (
<>
<SaveDebugReplayOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={mode == "timeline"}
isStarting={isStarting}
onSave={handleStart}
onCancel={handleCancel}
/>
<Overlay
open={mode == "select"}
onOpenChange={(open) => {
if (!open) {
setMode("none");
}
}}
>
{!isDesktop && (
<Trigger asChild>
<Button
className="flex items-center gap-2"
aria-label={t("title")}
size="sm"
onClick={() => {
const end = latestTime;
setRange({ after: end - 60, before: end });
setSelectedOption("1");
setMode("select");
}}
>
<LuBug className="size-5 rounded-md bg-secondary-foreground fill-secondary stroke-secondary p-1" />
{isDesktop && <div className="text-primary">{t("title")}</div>}
</Button>
</Trigger>
)}
<Content
className={
isDesktop
? "max-h-[90dvh] w-auto max-w-2xl overflow-visible sm:rounded-lg md:rounded-2xl"
: "max-h-[75dvh] overflow-y-auto rounded-lg px-4 pb-4 md:rounded-2xl"
}
>
<DebugReplayContent
currentTime={currentTime}
latestTime={latestTime}
range={range}
selectedOption={selectedOption}
isStarting={isStarting}
onSelectedOptionChange={handleTimeOptionChange}
onStart={handleStart}
onCancel={handleCancel}
setRange={setRange}
setMode={setMode}
/>
</Content>
</Overlay>
</>
);
}
type SaveDebugReplayOverlayProps = {
className: string;
show: boolean;
isStarting: boolean;
onSave: () => void;
onCancel: () => void;
};
export function SaveDebugReplayOverlay({
className,
show,
isStarting,
onSave,
onCancel,
}: SaveDebugReplayOverlayProps) {
const { t } = useTranslation(["views/replay"]);
return (
<div className={className}>
<div
className={cn(
"pointer-events-auto flex items-center justify-center gap-2 rounded-lg px-2",
show ? "duration-500 animate-in slide-in-from-top" : "invisible",
"mx-auto mt-5 text-center",
)}
>
<Button
className="flex items-center gap-1 text-primary"
aria-label={t("button.cancel", { ns: "common" })}
size="sm"
disabled={isStarting}
onClick={onCancel}
>
<LuX />
{t("button.cancel", { ns: "common" })}
</Button>
<Button
className="flex items-center gap-1"
aria-label={t("dialog.startButton")}
variant="select"
size="sm"
disabled={isStarting}
onClick={onSave}
>
{isStarting ? <ActivityIndicator className="size-4" /> : <LuPlay />}
{isStarting ? t("dialog.starting") : t("dialog.startButton")}
</Button>
</div>
</div>
);
}
+33 -261
View File
@@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from "react";
import { useCallback, useState } from "react";
import {
Dialog,
DialogContent,
@@ -12,16 +12,12 @@ import { Label } from "../ui/label";
import { RadioGroup, RadioGroupItem } from "../ui/radio-group";
import { Button } from "../ui/button";
import { ExportMode } from "@/types/filter";
import { FaArrowDown, FaArrowRight, FaCalendarAlt } from "react-icons/fa";
import { FaArrowDown } from "react-icons/fa";
import axios from "axios";
import { toast } from "sonner";
import { Input } from "../ui/input";
import { TimeRange } from "@/types/timeline";
import { useFormattedTimestamp } from "@/hooks/use-date-utils";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { Popover, PopoverContent, PopoverTrigger } from "../ui/popover";
import { TimezoneAwareCalendar } from "./ReviewActivityCalendar";
import {
Select,
SelectContent,
@@ -30,15 +26,15 @@ import {
SelectTrigger,
SelectValue,
} from "../ui/select";
import { isDesktop, isIOS, isMobile } from "react-device-detect";
import { isDesktop, isMobile } from "react-device-detect";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import SaveExportOverlay from "./SaveExportOverlay";
import { getUTCOffset } from "@/utils/dateUtil";
import { baseUrl } from "@/api/baseUrl";
import { cn } from "@/lib/utils";
import { GenericVideoPlayer } from "../player/GenericVideoPlayer";
import { useTranslation } from "react-i18next";
import { ExportCase } from "@/types/export";
import { CustomTimeSelector } from "./CustomTimeSelector";
const EXPORT_OPTIONS = [
"1",
@@ -167,31 +163,33 @@ export default function ExportDialog({
}
}}
>
<Trigger asChild>
<Button
className="flex items-center gap-2"
aria-label={t("menu.export", { ns: "common" })}
size="sm"
onClick={() => {
const now = new Date(latestTime * 1000);
let start = 0;
now.setHours(now.getHours() - 1);
start = now.getTime() / 1000;
setRange({
before: latestTime,
after: start,
});
setMode("select");
}}
>
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
{isDesktop && (
<div className="text-primary">
{t("menu.export", { ns: "common" })}
</div>
)}
</Button>
</Trigger>
{!isDesktop && (
<Trigger asChild>
<Button
className="flex items-center gap-2"
aria-label={t("menu.export", { ns: "common" })}
size="sm"
onClick={() => {
const now = new Date(latestTime * 1000);
let start = 0;
now.setHours(now.getHours() - 1);
start = now.getTime() / 1000;
setRange({
before: latestTime,
after: start,
});
setMode("select");
}}
>
<FaArrowDown className="rounded-md bg-secondary-foreground fill-secondary p-1" />
{isDesktop && (
<div className="text-primary">
{t("menu.export", { ns: "common" })}
</div>
)}
</Button>
</Trigger>
)}
<Content
className={
isDesktop
@@ -332,6 +330,8 @@ export function ExportContent({
latestTime={latestTime}
range={range}
setRange={setRange}
startLabel={t("export.time.start.title")}
endLabel={t("export.time.end.title")}
/>
)}
<Input
@@ -414,234 +414,6 @@ export function ExportContent({
);
}
type CustomTimeSelectorProps = {
latestTime: number;
range?: TimeRange;
setRange: (range: TimeRange | undefined) => void;
};
function CustomTimeSelector({
latestTime,
range,
setRange,
}: CustomTimeSelectorProps) {
const { t } = useTranslation(["components/dialog"]);
const { data: config } = useSWR<FrigateConfig>("config");
// times
const timezoneOffset = useMemo(
() =>
config?.ui.timezone
? Math.round(getUTCOffset(new Date(), config.ui.timezone))
: undefined,
[config?.ui.timezone],
);
const localTimeOffset = useMemo(
() =>
Math.round(
getUTCOffset(
new Date(),
Intl.DateTimeFormat().resolvedOptions().timeZone,
),
),
[],
);
const startTime = useMemo(() => {
let time = range?.after || latestTime - 3600;
if (timezoneOffset) {
time = time + (timezoneOffset - localTimeOffset) * 60;
}
return time;
}, [range, latestTime, timezoneOffset, localTimeOffset]);
const endTime = useMemo(() => {
let time = range?.before || latestTime;
if (timezoneOffset) {
time = time + (timezoneOffset - localTimeOffset) * 60;
}
return time;
}, [range, latestTime, timezoneOffset, localTimeOffset]);
const formattedStart = useFormattedTimestamp(
startTime,
config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }),
);
const formattedEnd = useFormattedTimestamp(
endTime,
config?.ui.time_format == "24hour"
? t("time.formattedTimestamp.24hour", { ns: "common" })
: t("time.formattedTimestamp.12hour", { ns: "common" }),
);
const startClock = useMemo(() => {
const date = new Date(startTime * 1000);
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
}, [startTime]);
const endClock = useMemo(() => {
const date = new Date(endTime * 1000);
return `${date.getHours().toString().padStart(2, "0")}:${date.getMinutes().toString().padStart(2, "0")}:${date.getSeconds().toString().padStart(2, "0")}`;
}, [endTime]);
// calendars
const [startOpen, setStartOpen] = useState(false);
const [endOpen, setEndOpen] = useState(false);
return (
<div
className={`mt-3 flex items-center rounded-lg bg-secondary text-secondary-foreground ${isDesktop ? "mx-8 gap-2 px-2" : "pl-2"}`}
>
<FaCalendarAlt />
<div className="flex flex-wrap items-center">
<Popover
modal={false}
open={startOpen}
onOpenChange={(open) => {
if (!open) {
setStartOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label={t("export.time.start.title")}
variant={startOpen ? "select" : "default"}
size="sm"
onClick={() => {
setStartOpen(true);
setEndOpen(false);
}}
>
{formattedStart}
</Button>
</PopoverTrigger>
<PopoverContent
disablePortal={isDesktop}
className="flex flex-col items-center"
>
<TimezoneAwareCalendar
timezone={config?.ui.timezone}
selectedDay={new Date(startTime * 1000)}
onSelect={(day) => {
if (!day) {
return;
}
setRange({
before: endTime,
after: day.getTime() / 1000 + 1,
});
}}
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="startTime"
type="time"
value={startClock}
step={isIOS ? "60" : "1"}
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, second] = isIOS
? [...clock.split(":"), "00"]
: clock.split(":");
const start = new Date(startTime * 1000);
start.setHours(
parseInt(hour),
parseInt(minute),
parseInt(second ?? 0),
0,
);
setRange({
before: endTime,
after: start.getTime() / 1000,
});
}}
/>
</PopoverContent>
</Popover>
<FaArrowRight className="size-4 text-primary" />
<Popover
modal={false}
open={endOpen}
onOpenChange={(open) => {
if (!open) {
setEndOpen(false);
}
}}
>
<PopoverTrigger asChild>
<Button
className={`text-primary ${isDesktop ? "" : "text-xs"}`}
aria-label={t("export.time.end.title")}
variant={endOpen ? "select" : "default"}
size="sm"
onClick={() => {
setEndOpen(true);
setStartOpen(false);
}}
>
{formattedEnd}
</Button>
</PopoverTrigger>
<PopoverContent
disablePortal={isDesktop}
className="flex flex-col items-center"
>
<TimezoneAwareCalendar
timezone={config?.ui.timezone}
selectedDay={new Date(endTime * 1000)}
onSelect={(day) => {
if (!day) {
return;
}
setRange({
after: startTime,
before: day.getTime() / 1000,
});
}}
/>
<SelectSeparator className="bg-secondary" />
<input
className="text-md mx-4 w-full border border-input bg-background p-1 text-secondary-foreground hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark]"
id="endTime"
type="time"
value={endClock}
step={isIOS ? "60" : "1"}
onChange={(e) => {
const clock = e.target.value;
const [hour, minute, second] = isIOS
? [...clock.split(":"), "00"]
: clock.split(":");
const end = new Date(endTime * 1000);
end.setHours(
parseInt(hour),
parseInt(minute),
parseInt(second ?? 0),
0,
);
setRange({
before: end.getTime() / 1000,
after: startTime,
});
}}
/>
</PopoverContent>
</Popover>
</div>
</div>
);
}
type ExportPreviewDialogProps = {
camera: string;
range?: TimeRange;
@@ -2,8 +2,13 @@ import { useCallback, useState } from "react";
import { Drawer, DrawerContent, DrawerTrigger } from "../ui/drawer";
import { Button } from "../ui/button";
import { FaArrowDown, FaCalendarAlt, FaCog, FaFilter } from "react-icons/fa";
import { LuBug } from "react-icons/lu";
import { TimeRange } from "@/types/timeline";
import { ExportContent, ExportPreviewDialog } from "./ExportDialog";
import {
DebugReplayContent,
SaveDebugReplayOverlay,
} from "./DebugReplayDialog";
import { ExportMode, GeneralFilter } from "@/types/filter";
import ReviewActivityCalendar from "./ReviewActivityCalendar";
import { SelectSeparator } from "../ui/select";
@@ -16,19 +21,32 @@ import {
import { getEndOfDayTimestamp } from "@/utils/dateUtil";
import { GeneralFilterContent } from "../filter/ReviewFilterGroup";
import { toast } from "sonner";
import axios from "axios";
import axios, { AxiosError } from "axios";
import SaveExportOverlay from "./SaveExportOverlay";
import { isIOS, isMobile } from "react-device-detect";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
type DrawerMode = "none" | "select" | "export" | "calendar" | "filter";
type DrawerMode =
| "none"
| "select"
| "export"
| "calendar"
| "filter"
| "debug-replay";
const DRAWER_FEATURES = ["export", "calendar", "filter"] as const;
const DRAWER_FEATURES = [
"export",
"calendar",
"filter",
"debug-replay",
] as const;
export type DrawerFeatures = (typeof DRAWER_FEATURES)[number];
const DEFAULT_DRAWER_FEATURES: DrawerFeatures[] = [
"export",
"calendar",
"filter",
"debug-replay",
];
type MobileReviewSettingsDrawerProps = {
@@ -45,6 +63,10 @@ type MobileReviewSettingsDrawerProps = {
recordingsSummary?: RecordingsSummary;
allLabels: string[];
allZones: string[];
debugReplayMode?: ExportMode;
debugReplayRange?: TimeRange;
setDebugReplayMode?: (mode: ExportMode) => void;
setDebugReplayRange?: (range: TimeRange | undefined) => void;
onUpdateFilter: (filter: ReviewFilter) => void;
setRange: (range: TimeRange | undefined) => void;
setMode: (mode: ExportMode) => void;
@@ -64,13 +86,26 @@ export default function MobileReviewSettingsDrawer({
recordingsSummary,
allLabels,
allZones,
debugReplayMode = "none",
debugReplayRange,
setDebugReplayMode = () => {},
setDebugReplayRange = () => {},
onUpdateFilter,
setRange,
setMode,
setShowExportPreview,
}: MobileReviewSettingsDrawerProps) {
const { t } = useTranslation(["views/recording", "components/dialog"]);
const { t } = useTranslation([
"views/recording",
"components/dialog",
"views/replay",
]);
const navigate = useNavigate();
const [drawerMode, setDrawerMode] = useState<DrawerMode>("none");
const [selectedReplayOption, setSelectedReplayOption] = useState<
"1" | "5" | "custom" | "timeline"
>("1");
const [isDebugReplayStarting, setIsDebugReplayStarting] = useState(false);
// exports
@@ -140,6 +175,76 @@ export default function MobileReviewSettingsDrawer({
});
}, [camera, name, range, selectedCaseId, setRange, setName, setMode, t]);
const onStartDebugReplay = useCallback(async () => {
if (
!debugReplayRange ||
debugReplayRange.before <= debugReplayRange.after
) {
toast.error(
t("dialog.toast.error", {
error: "End time must be after start time",
ns: "views/replay",
}),
{ position: "top-center" },
);
return;
}
setIsDebugReplayStarting(true);
try {
const response = await axios.post("debug_replay/start", {
camera: camera,
start_time: debugReplayRange.after,
end_time: debugReplayRange.before,
});
if (response.status === 200) {
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
position: "top-center",
});
setDebugReplayMode("none");
setDebugReplayRange(undefined);
setDrawerMode("none");
navigate("/replay");
}
} catch (error) {
const axiosError = error as AxiosError<{
message?: string;
detail?: string;
}>;
const errorMessage =
axiosError.response?.data?.message ||
axiosError.response?.data?.detail ||
"Unknown error";
if (axiosError.response?.status === 409) {
toast.error(t("dialog.toast.alreadyActive", { ns: "views/replay" }), {
position: "top-center",
});
} else {
toast.error(
t("dialog.toast.error", {
error: errorMessage,
ns: "views/replay",
}),
{
position: "top-center",
},
);
}
} finally {
setIsDebugReplayStarting(false);
}
}, [
camera,
debugReplayRange,
navigate,
setDebugReplayMode,
setDebugReplayRange,
t,
]);
// filters
const [currentFilter, setCurrentFilter] = useState<GeneralFilter>({
@@ -196,6 +301,26 @@ export default function MobileReviewSettingsDrawer({
{t("filter")}
</Button>
)}
{features.includes("debug-replay") && (
<Button
className="flex w-full items-center justify-center gap-2"
aria-label={t("title", { ns: "views/replay" })}
onClick={() => {
const now = new Date(latestTime * 1000);
now.setHours(now.getHours() - 1);
setDebugReplayRange({
after: now.getTime() / 1000,
before: latestTime,
});
setSelectedReplayOption("1");
setDrawerMode("debug-replay");
setDebugReplayMode("select");
}}
>
<LuBug className="size-5 rounded-md bg-secondary-foreground fill-secondary stroke-secondary p-1" />
{t("title", { ns: "views/replay" })}
</Button>
)}
</div>
);
} else if (drawerMode == "export") {
@@ -311,6 +436,47 @@ export default function MobileReviewSettingsDrawer({
/>
</div>
);
} else if (drawerMode == "debug-replay") {
const handleTimeOptionChange = (
option: "1" | "5" | "custom" | "timeline",
) => {
setSelectedReplayOption(option);
if (option === "custom" || option === "timeline") {
return;
}
const hours = parseInt(option);
const end = latestTime;
const now = new Date(end * 1000);
now.setHours(now.getHours() - hours);
setDebugReplayRange({ after: now.getTime() / 1000, before: end });
};
content = (
<DebugReplayContent
currentTime={currentTime}
latestTime={latestTime}
range={debugReplayRange}
selectedOption={selectedReplayOption}
isStarting={isDebugReplayStarting}
onSelectedOptionChange={handleTimeOptionChange}
onStart={onStartDebugReplay}
onCancel={() => {
setDebugReplayMode("none");
setDebugReplayRange(undefined);
setDrawerMode("select");
}}
setRange={setDebugReplayRange}
setMode={(mode) => {
setDebugReplayMode(mode);
if (mode == "timeline") {
setDrawerMode("none");
}
}}
/>
);
}
return (
@@ -322,6 +488,16 @@ export default function MobileReviewSettingsDrawer({
onCancel={() => setMode("none")}
onPreview={() => setShowExportPreview(true)}
/>
<SaveDebugReplayOverlay
className="pointer-events-none absolute left-1/2 top-8 z-50 -translate-x-1/2"
show={debugReplayRange != undefined && debugReplayMode == "timeline"}
isStarting={isDebugReplayStarting}
onSave={onStartDebugReplay}
onCancel={() => {
setDebugReplayMode("none");
setDebugReplayRange(undefined);
}}
/>
<ExportPreviewDialog
camera={camera}
range={range}
@@ -354,7 +530,9 @@ export default function MobileReviewSettingsDrawer({
/>
</Button>
</DrawerTrigger>
<DrawerContent className="mx-1 flex max-h-[80dvh] flex-col items-center gap-2 overflow-hidden rounded-t-2xl px-4 pb-4">
<DrawerContent
className={`mx-1 flex max-h-[80dvh] flex-col items-center gap-2 rounded-t-2xl px-4 pb-4 ${drawerMode == "export" || drawerMode == "debug-replay" ? "overflow-visible" : "overflow-hidden"}`}
>
{content}
</DrawerContent>
</Drawer>
+72 -2
View File
@@ -12,8 +12,11 @@ import { useNavigate } from "react-router-dom";
import { useTranslation } from "react-i18next";
import { Event } from "@/types/event";
import { FrigateConfig } from "@/types/frigateConfig";
import { useState } from "react";
import { useCallback, useState } from "react";
import { useIsAdmin } from "@/hooks/use-is-admin";
import axios from "axios";
import { toast } from "sonner";
import { Button } from "../ui/button";
type EventMenuProps = {
event: Event;
@@ -34,9 +37,10 @@ export default function EventMenu({
}: EventMenuProps) {
const apiHost = useApiHost();
const navigate = useNavigate();
const { t } = useTranslation("views/explore");
const { t } = useTranslation(["views/explore", "views/replay"]);
const [isOpen, setIsOpen] = useState(false);
const isAdmin = useIsAdmin();
const [isStarting, setIsStarting] = useState(false);
const handleObjectSelect = () => {
if (isSelected) {
@@ -46,6 +50,59 @@ export default function EventMenu({
}
};
const handleDebugReplay = useCallback(
(event: Event) => {
setIsStarting(true);
axios
.post("debug_replay/start", {
camera: event.camera,
start_time: event.start_time,
end_time: event.end_time,
})
.then((response) => {
if (response.status === 200) {
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
position: "top-center",
});
navigate("/replay");
}
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
if (error.response?.status === 409) {
toast.error(
t("dialog.toast.alreadyActive", { ns: "views/replay" }),
{
position: "top-center",
closeButton: true,
dismissible: false,
action: (
<a href="/replay" target="_blank" rel="noopener noreferrer">
<Button>
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
</Button>
</a>
),
},
);
} else {
toast.error(t("dialog.toast.error", { error: errorMessage }), {
position: "top-center",
});
}
})
.finally(() => {
setIsStarting(false);
});
},
[navigate, t],
);
return (
<>
<span tabIndex={0} className="sr-only" />
@@ -117,6 +174,19 @@ export default function EventMenu({
{t("itemMenu.findSimilar.label")}
</DropdownMenuItem>
)}
{event.has_clip && (
<DropdownMenuItem
className="cursor-pointer"
disabled={isStarting}
onSelect={() => {
handleDebugReplay(event);
}}
>
{isStarting
? t("dialog.starting", { ns: "views/replay" })
: t("itemMenu.debugReplay.label")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenuPortal>
</DropdownMenu>
+608
View File
@@ -0,0 +1,608 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import useSWR from "swr";
import { WsFeedMessage } from "@/api/ws";
import { useWsMessageBuffer } from "@/hooks/use-ws-message-buffer";
import WsMessageRow from "./WsMessageRow";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { FaEraser, FaFilter, FaPause, FaPlay, FaVideo } from "react-icons/fa";
import { FrigateConfig } from "@/types/frigateConfig";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Drawer, DrawerContent, DrawerTrigger } from "@/components/ui/drawer";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { isMobile } from "react-device-detect";
import { isReplayCamera } from "@/utils/cameraUtil";
type TopicCategory =
| "events"
| "camera_activity"
| "system"
| "reviews"
| "classification"
| "face_recognition"
| "lpr";
const ALL_TOPIC_CATEGORIES: TopicCategory[] = [
"events",
"reviews",
"classification",
"face_recognition",
"lpr",
"camera_activity",
"system",
];
const PRESET_TOPICS: Record<TopicCategory, Set<string>> = {
events: new Set(["events", "triggers"]),
reviews: new Set(["reviews"]),
classification: new Set(["tracked_object_update"]),
face_recognition: new Set(["tracked_object_update"]),
lpr: new Set(["tracked_object_update"]),
camera_activity: new Set(["camera_activity", "audio_detections"]),
system: new Set([
"stats",
"model_state",
"job_state",
"embeddings_reindex_progress",
"audio_transcription_state",
"birdseye_layout",
]),
};
// Maps tracked_object_update payload type to TopicCategory
const TRACKED_UPDATE_TYPE_MAP: Record<string, TopicCategory> = {
classification: "classification",
face: "face_recognition",
lpr: "lpr",
};
// camera_activity preset also matches topics with camera prefix patterns
const CAMERA_ACTIVITY_TOPIC_PATTERNS = [
"/motion",
"/audio",
"/detect",
"/recordings",
"/enabled",
"/snapshots",
"/ptz",
];
function matchesCategories(
msg: WsFeedMessage,
categories: TopicCategory[] | undefined,
): boolean {
// undefined means all topics
if (!categories) return true;
const { topic, payload } = msg;
// Handle tracked_object_update with payload-based sub-categories
if (topic === "tracked_object_update") {
// payload might be a JSON string or a parsed object
let data: unknown = payload;
if (typeof data === "string") {
try {
data = JSON.parse(data);
} catch {
// not valid JSON, fall through
}
}
const updateType =
data && typeof data === "object" && "type" in data
? (data as { type: string }).type
: undefined;
if (updateType && updateType in TRACKED_UPDATE_TYPE_MAP) {
const mappedCategory = TRACKED_UPDATE_TYPE_MAP[updateType];
return categories.includes(mappedCategory);
}
// tracked_object_update with other types (e.g. "description") falls under "events"
return categories.includes("events");
}
for (const cat of categories) {
const topicSet = PRESET_TOPICS[cat];
if (topicSet.has(topic)) return true;
if (cat === "camera_activity") {
if (
CAMERA_ACTIVITY_TOPIC_PATTERNS.some((pattern) =>
topic.includes(pattern),
)
) {
return true;
}
}
}
return false;
}
type WsMessageFeedProps = {
maxSize?: number;
defaultCamera?: string;
lockedCamera?: string;
showCameraBadge?: boolean;
};
export default function WsMessageFeed({
maxSize = 500,
defaultCamera,
lockedCamera,
showCameraBadge = true,
}: WsMessageFeedProps) {
const { t } = useTranslation(["views/system"]);
const [paused, setPaused] = useState(false);
// undefined = all topics
const [selectedTopics, setSelectedTopics] = useState<
TopicCategory[] | undefined
>(undefined);
// undefined = all cameras
const [selectedCameras, setSelectedCameras] = useState<string[] | undefined>(
() => {
if (lockedCamera) return [lockedCamera];
if (defaultCamera) return [defaultCamera];
return undefined;
},
);
const { messages, clear } = useWsMessageBuffer(maxSize, paused, {
cameraFilter: selectedCameras,
});
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const availableCameras = useMemo(() => {
if (!config?.cameras) return [];
return Object.keys(config.cameras)
.filter((name) => {
const cam = config.cameras[name];
return !isReplayCamera(name) && cam.enabled_in_config;
})
.sort();
}, [config]);
const filteredMessages = useMemo(() => {
return messages.filter((msg: WsFeedMessage) => {
if (!matchesCategories(msg, selectedTopics)) return false;
return true;
});
}, [messages, selectedTopics]);
// Auto-scroll logic
const scrollContainerRef = useRef<HTMLDivElement>(null);
const autoScrollRef = useRef(true);
const handleScroll = useCallback(() => {
const el = scrollContainerRef.current;
if (!el) return;
const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
autoScrollRef.current = atBottom;
}, []);
useEffect(() => {
const el = scrollContainerRef.current;
if (!el || !autoScrollRef.current) return;
el.scrollTop = el.scrollHeight;
}, [filteredMessages.length]);
return (
<div className="flex size-full flex-col">
{/* Toolbar */}
<div className="flex flex-row flex-wrap items-center justify-between gap-2 border-b border-secondary p-2">
<div className="flex flex-row flex-wrap items-center gap-1">
<TopicFilterButton
selectedTopics={selectedTopics}
updateTopicFilter={setSelectedTopics}
/>
{!lockedCamera && (
<WsCamerasFilterButton
allCameras={availableCameras}
selectedCameras={selectedCameras}
updateCameraFilter={setSelectedCameras}
/>
)}
</div>
<div className="flex flex-row items-center gap-2">
<Badge variant="secondary" className="text-xs text-primary-variant">
{t("logs.websocket.count", {
count: filteredMessages.length,
})}
</Badge>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-xs"
onClick={() => setPaused(!paused)}
aria-label={
paused ? t("logs.websocket.resume") : t("logs.websocket.pause")
}
>
{paused ? (
<FaPlay className="size-2.5" />
) : (
<FaPause className="size-2.5" />
)}
{paused ? t("logs.websocket.resume") : t("logs.websocket.pause")}
</Button>
<Button
variant="outline"
size="sm"
className="h-7 gap-1 px-2 text-xs"
onClick={clear}
aria-label={t("logs.websocket.clear")}
>
<FaEraser className="size-2.5" />
{t("logs.websocket.clear")}
</Button>
</div>
</div>
</div>
{/* Feed area */}
<div
ref={scrollContainerRef}
onScroll={handleScroll}
className="scrollbar-container flex-1 overflow-y-auto"
>
{filteredMessages.length === 0 ? (
<div className="flex size-full items-center justify-center p-8 text-sm text-muted-foreground">
{t("logs.websocket.empty")}
</div>
) : (
filteredMessages.map((msg: WsFeedMessage) => (
<WsMessageRow
key={msg.id}
message={msg}
showCameraBadge={showCameraBadge}
/>
))
)}
</div>
</div>
);
}
// Topic Filter Button
type TopicFilterButtonProps = {
selectedTopics: TopicCategory[] | undefined;
updateTopicFilter: (topics: TopicCategory[] | undefined) => void;
};
function TopicFilterButton({
selectedTopics,
updateTopicFilter,
}: TopicFilterButtonProps) {
const { t } = useTranslation(["views/system"]);
const [open, setOpen] = useState(false);
const [currentTopics, setCurrentTopics] = useState<
TopicCategory[] | undefined
>(selectedTopics);
useEffect(() => {
setCurrentTopics(selectedTopics);
}, [selectedTopics]);
const isFiltered = selectedTopics !== undefined;
const trigger = (
<Button
variant={isFiltered ? "select" : "outline"}
size="sm"
className="h-7 gap-1 px-2 text-xs"
aria-label={t("logs.websocket.filter.all")}
>
<FaFilter
className={`size-2.5 ${isFiltered ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<span className={isFiltered ? "text-selected-foreground" : ""}>
{t("logs.websocket.filter.topics")}
</span>
</Button>
);
const content = (
<TopicFilterContent
currentTopics={currentTopics}
setCurrentTopics={setCurrentTopics}
onApply={() => {
updateTopicFilter(currentTopics);
setOpen(false);
}}
onReset={() => {
setCurrentTopics(undefined);
updateTopicFilter(undefined);
}}
/>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) setCurrentTopics(selectedTopics);
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={(open) => {
if (!open) setCurrentTopics(selectedTopics);
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}
type TopicFilterContentProps = {
currentTopics: TopicCategory[] | undefined;
setCurrentTopics: (topics: TopicCategory[] | undefined) => void;
onApply: () => void;
onReset: () => void;
};
function TopicFilterContent({
currentTopics,
setCurrentTopics,
onApply,
onReset,
}: TopicFilterContentProps) {
const { t } = useTranslation(["views/system", "common"]);
return (
<>
<div className="flex flex-col gap-2.5 p-4">
<FilterSwitch
isChecked={currentTopics === undefined}
label={t("logs.websocket.filter.all")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentTopics(undefined);
}
}}
/>
<DropdownMenuSeparator />
{ALL_TOPIC_CATEGORIES.map((cat) => (
<FilterSwitch
key={cat}
isChecked={currentTopics?.includes(cat) ?? false}
label={t(`logs.websocket.filter.${cat}`)}
onCheckedChange={(isChecked) => {
if (isChecked) {
const updated = currentTopics ? [...currentTopics, cat] : [cat];
setCurrentTopics(updated);
} else {
const updated = currentTopics
? currentTopics.filter((c) => c !== cat)
: [];
if (updated.length === 0) {
setCurrentTopics(undefined);
} else {
setCurrentTopics(updated);
}
}
}}
/>
))}
</div>
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
aria-label={t("button.apply", { ns: "common" })}
variant="select"
size="sm"
onClick={onApply}
>
{t("button.apply", { ns: "common" })}
</Button>
<Button
aria-label={t("button.reset", { ns: "common" })}
size="sm"
onClick={onReset}
>
{t("button.reset", { ns: "common" })}
</Button>
</div>
</>
);
}
// Camera Filter Button
type WsCamerasFilterButtonProps = {
allCameras: string[];
selectedCameras: string[] | undefined;
updateCameraFilter: (cameras: string[] | undefined) => void;
};
function WsCamerasFilterButton({
allCameras,
selectedCameras,
updateCameraFilter,
}: WsCamerasFilterButtonProps) {
const { t } = useTranslation(["views/system", "common"]);
const [open, setOpen] = useState(false);
const [currentCameras, setCurrentCameras] = useState<string[] | undefined>(
selectedCameras,
);
useEffect(() => {
setCurrentCameras(selectedCameras);
}, [selectedCameras]);
const isFiltered = selectedCameras !== undefined;
const trigger = (
<Button
variant={isFiltered ? "select" : "outline"}
size="sm"
className="h-7 gap-1 px-2 text-xs"
aria-label={t("logs.websocket.filter.all_cameras")}
>
<FaVideo
className={`size-2.5 ${isFiltered ? "text-selected-foreground" : "text-secondary-foreground"}`}
/>
<span className={isFiltered ? "text-selected-foreground" : ""}>
{!selectedCameras
? t("logs.websocket.filter.all_cameras")
: t("logs.websocket.filter.cameras_count", {
count: selectedCameras.length,
})}
</span>
</Button>
);
const content = (
<WsCamerasFilterContent
allCameras={allCameras}
currentCameras={currentCameras}
setCurrentCameras={setCurrentCameras}
onApply={() => {
updateCameraFilter(currentCameras);
setOpen(false);
}}
onReset={() => {
setCurrentCameras(undefined);
updateCameraFilter(undefined);
}}
/>
);
if (isMobile) {
return (
<Drawer
open={open}
onOpenChange={(open) => {
if (!open) setCurrentCameras(selectedCameras);
setOpen(open);
}}
>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent className="max-h-[75dvh] overflow-hidden">
{content}
</DrawerContent>
</Drawer>
);
}
return (
<DropdownMenu
modal={false}
open={open}
onOpenChange={(open) => {
if (!open) setCurrentCameras(selectedCameras);
setOpen(open);
}}
>
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
<DropdownMenuContent>{content}</DropdownMenuContent>
</DropdownMenu>
);
}
type WsCamerasFilterContentProps = {
allCameras: string[];
currentCameras: string[] | undefined;
setCurrentCameras: (cameras: string[] | undefined) => void;
onApply: () => void;
onReset: () => void;
};
function WsCamerasFilterContent({
allCameras,
currentCameras,
setCurrentCameras,
onApply,
onReset,
}: WsCamerasFilterContentProps) {
const { t } = useTranslation(["views/system", "common"]);
return (
<>
<div className="scrollbar-container flex max-h-[60dvh] flex-col gap-2.5 overflow-y-auto p-4">
<FilterSwitch
isChecked={currentCameras === undefined}
label={t("logs.websocket.filter.all_cameras")}
onCheckedChange={(isChecked) => {
if (isChecked) {
setCurrentCameras(undefined);
}
}}
/>
<DropdownMenuSeparator />
{allCameras.map((cam) => (
<FilterSwitch
key={cam}
isChecked={currentCameras?.includes(cam) ?? false}
label={cam}
type="camera"
onCheckedChange={(isChecked) => {
if (isChecked) {
const updated = currentCameras ? [...currentCameras] : [];
if (!updated.includes(cam)) {
updated.push(cam);
}
setCurrentCameras(updated);
} else {
const updated = currentCameras ? [...currentCameras] : [];
if (updated.length > 1) {
updated.splice(updated.indexOf(cam), 1);
setCurrentCameras(updated);
}
}
}}
/>
))}
</div>
<DropdownMenuSeparator />
<div className="flex items-center justify-evenly p-2">
<Button
aria-label={t("button.apply", { ns: "common" })}
variant="select"
size="sm"
disabled={currentCameras?.length === 0}
onClick={onApply}
>
{t("button.apply", { ns: "common" })}
</Button>
<Button
aria-label={t("button.reset", { ns: "common" })}
size="sm"
onClick={onReset}
>
{t("button.reset", { ns: "common" })}
</Button>
</div>
</>
);
}
+433
View File
@@ -0,0 +1,433 @@
import { memo, useCallback, useState } from "react";
import { WsFeedMessage } from "@/api/ws";
import { cn } from "@/lib/utils";
import { ChevronRight } from "lucide-react";
import { useTranslation } from "react-i18next";
import { extractCameraName } from "@/utils/wsUtil";
import { getIconForLabel } from "@/utils/iconUtil";
import { LuCheck, LuCopy } from "react-icons/lu";
type TopicCategory = "events" | "camera_activity" | "system" | "other";
const TOPIC_CATEGORY_COLORS: Record<TopicCategory, string> = {
events: "bg-blue-500/20 text-blue-700 dark:text-blue-300 border-blue-500/30",
camera_activity:
"bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
system:
"bg-purple-500/20 text-purple-700 dark:text-purple-300 border-purple-500/30",
other: "bg-gray-500/20 text-gray-700 dark:text-gray-300 border-gray-500/30",
};
const EVENT_TYPE_COLORS: Record<string, string> = {
start:
"bg-green-500/20 text-green-700 dark:text-green-300 border-green-500/30",
update: "bg-cyan-500/20 text-cyan-700 dark:text-cyan-300 border-cyan-500/30",
end: "bg-red-500/20 text-red-700 dark:text-red-300 border-red-500/30",
};
const TRACKED_OBJECT_UPDATE_COLORS: Record<string, string> = {
description:
"bg-amber-500/20 text-amber-700 dark:text-amber-300 border-amber-500/30",
face: "bg-pink-500/20 text-pink-700 dark:text-pink-300 border-pink-500/30",
lpr: "bg-yellow-500/20 text-yellow-700 dark:text-yellow-300 border-yellow-500/30",
classification:
"bg-violet-500/20 text-violet-700 dark:text-violet-300 border-violet-500/30",
};
function getEventTypeColor(eventType: string): string {
return (
EVENT_TYPE_COLORS[eventType] ||
"bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30"
);
}
function getTrackedObjectTypeColor(objectType: string): string {
return (
TRACKED_OBJECT_UPDATE_COLORS[objectType] ||
"bg-orange-500/20 text-orange-700 dark:text-orange-300 border-orange-500/30"
);
}
const EVENT_TOPICS = new Set([
"events",
"reviews",
"tracked_object_update",
"triggers",
]);
const SYSTEM_TOPICS = new Set([
"stats",
"model_state",
"job_state",
"embeddings_reindex_progress",
"audio_transcription_state",
"birdseye_layout",
]);
function getTopicCategory(topic: string): TopicCategory {
if (EVENT_TOPICS.has(topic)) return "events";
if (SYSTEM_TOPICS.has(topic)) return "system";
if (
topic === "camera_activity" ||
topic === "audio_detections" ||
topic.includes("/motion") ||
topic.includes("/audio") ||
topic.includes("/detect") ||
topic.includes("/recordings") ||
topic.includes("/enabled") ||
topic.includes("/snapshots") ||
topic.includes("/ptz")
) {
return "camera_activity";
}
return "other";
}
function formatTimestamp(ts: number): string {
const d = new Date(ts);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
const ms = String(d.getMilliseconds()).padStart(3, "0");
return `${hh}:${mm}:${ss}.${ms}`;
}
function getPayloadSummary(
topic: string,
payload: unknown,
hideType: boolean = false,
): string {
if (payload === null || payload === undefined) return "";
try {
const data = typeof payload === "string" ? JSON.parse(payload) : payload;
if (typeof data === "object" && data !== null) {
// Topic-specific summary handlers
if (topic === "tracked_object_update") {
return getTrackedObjectUpdateSummary(data);
}
if ("type" in data && "label" in (data.after || data)) {
const after = data.after || data;
const parts: string[] = [];
if (!hideType) {
parts.push(`type: ${data.type}`);
}
parts.push(`label: ${after.label || "?"}`);
// Add sub_label for events topic if present
if (topic === "events" && after.sub_label) {
parts.push(`sub_label: ${after.sub_label}`);
}
return parts.join(", ");
}
if ("type" in data && "camera" in data) {
if (hideType) {
return `camera: ${data.camera}`;
}
return `type: ${data.type}, camera: ${data.camera}`;
}
const keys = Object.keys(data);
if (keys.length <= 3) {
return keys
.map((k) => {
const v = data[k];
if (typeof v === "string" || typeof v === "number") {
return `${k}: ${v}`;
}
return k;
})
.join(", ");
}
return `{${keys.length} keys}`;
}
const str = String(data);
return str.length > 80 ? str.slice(0, 80) + "…" : str;
} catch {
const str = String(payload);
return str.length > 80 ? str.slice(0, 80) + "…" : str;
}
}
function getTrackedObjectUpdateSummary(data: unknown): string {
if (typeof data !== "object" || data === null) return "";
const obj = data as Record<string, unknown>;
const type = obj.type as string;
switch (type) {
case "description":
return obj.description ? `${obj.description}` : "no description";
case "face": {
const name = obj.name as string | undefined;
return name || "unknown";
}
case "lpr": {
const name = obj.name as string | undefined;
const plate = obj.plate as string | undefined;
return name || plate || "unknown";
}
case "classification": {
const parts: string[] = [];
const model = obj.model as string | undefined;
const subLabel = obj.sub_label as string | undefined;
const attribute = obj.attribute as string | undefined;
if (model) parts.push(`model: ${model}`);
if (subLabel) parts.push(`sub_label: ${subLabel}`);
if (attribute) parts.push(`attribute: ${attribute}`);
return parts.length > 0 ? parts.join(", ") : "classification";
}
default:
return type || "unknown";
}
}
function extractTypeForBadge(payload: unknown): string | null {
if (payload === null || payload === undefined) return null;
try {
const data = typeof payload === "string" ? JSON.parse(payload) : payload;
if (typeof data === "object" && data !== null && "type" in data) {
return data.type as string;
}
} catch {
// ignore
}
return null;
}
function shouldShowTypeBadge(type: string | null): boolean {
if (!type) return false;
return true;
}
function shouldShowSummary(topic: string): boolean {
// Hide summary for reviews topic
return topic !== "reviews";
}
function escapeHtml(s: string): string {
return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
}
function highlightJson(value: unknown): string {
// Try to auto-parse JSON strings
if (typeof value === "string") {
try {
const parsed = JSON.parse(value);
if (typeof parsed === "object" && parsed !== null) {
value = parsed;
}
} catch {
// not JSON
}
}
const raw = JSON.stringify(value, null, 2) ?? String(value);
// Single regex pass to colorize JSON tokens
return raw.replace(
/("(?:[^"\\]|\\.)*")\s*:|("(?:[^"\\]|\\.)*")|(true|false|null)|(-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?)/g,
(match, key: string, str: string, keyword: string, num: string) => {
if (key) {
return `<span class="text-indigo-400">${escapeHtml(key)}</span>:`;
}
if (str) {
const content = escapeHtml(str);
return `<span class="text-green-500">${content}</span>`;
}
if (keyword) {
return `<span class="text-orange-500">${keyword}</span>`;
}
if (num) {
return `<span class="text-cyan-500">${num}</span>`;
}
return match;
},
);
}
function CopyJsonButton({ payload }: { payload: unknown }) {
const [copied, setCopied] = useState(false);
const handleCopy = useCallback(
(e: React.MouseEvent) => {
e.stopPropagation();
const text =
typeof payload === "string"
? payload
: JSON.stringify(payload, null, 2);
navigator.clipboard.writeText(text).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
},
[payload],
);
return (
<button
onClick={handleCopy}
className="rounded p-1 text-muted-foreground transition-colors hover:bg-secondary hover:text-foreground"
aria-label="Copy JSON"
>
{copied ? (
<LuCheck className="size-3.5 text-green-500" />
) : (
<LuCopy className="size-3.5" />
)}
</button>
);
}
type WsMessageRowProps = {
message: WsFeedMessage;
showCameraBadge?: boolean;
};
const WsMessageRow = memo(function WsMessageRow({
message,
showCameraBadge = true,
}: WsMessageRowProps) {
const { t } = useTranslation(["views/system"]);
const [expanded, setExpanded] = useState(false);
const category = getTopicCategory(message.topic);
const cameraName = extractCameraName(message);
const messageType = extractTypeForBadge(message.payload);
const showTypeBadge = shouldShowTypeBadge(messageType);
const summary = getPayloadSummary(message.topic, message.payload);
const eventLabel = (() => {
try {
const data =
typeof message.payload === "string"
? JSON.parse(message.payload)
: message.payload;
if (typeof data === "object" && data !== null) {
return (data.after?.label as string) || (data.label as string) || null;
}
} catch {
// ignore
}
return null;
})();
const parsedPayload = (() => {
try {
return typeof message.payload === "string"
? JSON.parse(message.payload)
: message.payload;
} catch {
return message.payload;
}
})();
const handleToggle = useCallback(() => {
setExpanded((prev) => !prev);
}, []);
// Determine which color function to use based on topic
const getTypeBadgeColor = (type: string | null) => {
if (!type) return "";
if (message.topic === "tracked_object_update") {
return getTrackedObjectTypeColor(type);
}
return getEventTypeColor(type);
};
return (
<div className="border-b border-secondary/50">
<div
className={cn(
"flex cursor-pointer items-center gap-2 px-2 py-1.5 transition-colors hover:bg-muted/50",
expanded && "bg-muted/30",
)}
onClick={handleToggle}
>
<ChevronRight
className={cn(
"size-3.5 shrink-0 text-muted-foreground transition-transform",
expanded && "rotate-90",
)}
/>
<span className="shrink-0 font-mono text-xs text-muted-foreground">
{formatTimestamp(message.timestamp)}
</span>
<span
className={cn(
"shrink-0 rounded border px-1.5 py-0.5 font-mono text-xs",
TOPIC_CATEGORY_COLORS[category],
)}
>
{message.topic}
</span>
{showTypeBadge && messageType && (
<span
className={cn(
"shrink-0 rounded border px-1.5 py-0.5 text-xs",
getTypeBadgeColor(messageType),
)}
>
{messageType}
</span>
)}
{showCameraBadge && cameraName && (
<span className="shrink-0 rounded bg-secondary px-1.5 py-0.5 text-xs text-secondary-foreground">
{cameraName}
</span>
)}
{eventLabel && (
<span className="shrink-0">
{getIconForLabel(
eventLabel,
"object",
"size-3.5 text-primary-variant",
)}
</span>
)}
{shouldShowSummary(message.topic) && (
<span className="min-w-0 truncate text-xs text-muted-foreground">
{summary}
</span>
)}
</div>
{expanded && (
<div className="border-t border-secondary/30 bg-background_alt/50 px-4 py-2">
<div className="mb-1 flex items-center justify-between">
<span className="text-xs font-medium uppercase tracking-wider text-muted-foreground">
{t("logs.websocket.expanded.payload")}
</span>
<CopyJsonButton payload={parsedPayload} />
</div>
<pre
className="scrollbar-container max-h-[60vh] overflow-auto rounded bg-background p-2 font-mono text-[11px] leading-relaxed"
dangerouslySetInnerHTML={{ __html: highlightJson(parsedPayload) }}
/>
</div>
)}
</div>
);
});
export default WsMessageRow;
+6 -3
View File
@@ -2,6 +2,7 @@ import { useContext } from "react";
import { AuthContext } from "@/context/auth-context";
import useSWR from "swr";
import { FrigateConfig } from "@/types/frigateConfig";
import { isReplayCamera } from "@/utils/cameraUtil";
export function useAllowedCameras() {
const { auth } = useContext(AuthContext);
@@ -14,9 +15,11 @@ export function useAllowedCameras() {
auth.user?.role === "admin" ||
!auth.isAuthenticated // anonymous internal port
) {
// return all cameras
return config?.cameras ? Object.keys(config.cameras) : [];
// return all cameras, excluding replay cameras
return config?.cameras
? Object.keys(config.cameras).filter((name) => !isReplayCamera(name))
: [];
}
return auth.allowedCameras || [];
return (auth.allowedCameras || []).filter((name) => !isReplayCamera(name));
}
+10 -6
View File
@@ -26,7 +26,7 @@ type useCameraActivityReturn = {
};
export function useCameraActivity(
camera: CameraConfig,
camera: CameraConfig | undefined,
revalidateOnFocus: boolean = true,
): useCameraActivityReturn {
const { data: config } = useSWR<FrigateConfig>("config", {
@@ -47,7 +47,7 @@ export function useCameraActivity(
// init camera activity
const { payload: updatedCameraState } = useInitialCameraState(
camera.name,
camera?.name ?? "",
revalidateOnFocus,
);
useEffect(() => {
@@ -60,7 +60,7 @@ export function useCameraActivity(
const memoizedAudioState = useDeepMemo(updatedAudioState);
useEffect(() => {
if (memoizedAudioState) {
if (memoizedAudioState && camera?.name) {
setAudioDetections(memoizedAudioState[camera.name]);
}
}, [memoizedAudioState, camera]);
@@ -72,8 +72,8 @@ export function useCameraActivity(
[objects],
);
const { payload: cameraEnabled } = useEnabledState(camera.name);
const { payload: detectingMotion } = useMotionActivity(camera.name);
const { payload: cameraEnabled } = useEnabledState(camera?.name ?? "");
const { payload: detectingMotion } = useMotionActivity(camera?.name ?? "");
const { payload: event } = useFrigateEvents();
const updatedEvent = useDeepMemo(event);
@@ -91,7 +91,7 @@ export function useCameraActivity(
return;
}
if (updatedEvent.after.camera !== camera.name) {
if (!camera?.name || updatedEvent.after.camera !== camera.name) {
return;
}
@@ -158,6 +158,10 @@ export function useCameraActivity(
return false;
}
if (!camera?.name) {
return false;
}
return (
cameras[camera.name]?.camera_fps == 0 && stats["service"].uptime > 60
);
+30 -2
View File
@@ -9,6 +9,7 @@ import { useMemo } from "react";
import useSWR from "swr";
import useDeepMemo from "./use-deep-memo";
import { capitalizeAll, capitalizeFirstLetter } from "@/utils/stringUtil";
import { isReplayCamera } from "@/utils/cameraUtil";
import { useFrigateStats } from "@/api/ws";
import { useTranslation } from "react-i18next";
@@ -16,6 +17,9 @@ import { useTranslation } from "react-i18next";
export default function useStats(stats: FrigateStats | undefined) {
const { t } = useTranslation(["views/system"]);
const { data: config } = useSWR<FrigateConfig>("config");
const { data: debugReplayStatus } = useSWR("debug_replay/status", {
revalidateOnFocus: false,
});
const memoizedStats = useDeepMemo(stats);
@@ -74,6 +78,11 @@ export default function useStats(stats: FrigateStats | undefined) {
return;
}
// Skip replay cameras
if (isReplayCamera(name)) {
return;
}
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
if (config.cameras[name].enabled && cam["camera_fps"] == 0) {
problems.push({
@@ -96,7 +105,15 @@ export default function useStats(stats: FrigateStats | undefined) {
);
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
// Skip ffmpeg warnings for replay cameras when debug replay is active
if (
!isNaN(ffmpegAvg) &&
ffmpegAvg >= CameraFfmpegThreshold.error &&
!(
debugReplayStatus?.active && debugReplayStatus?.replay_camera === name
)
) {
problems.push({
text: t("stats.ffmpegHighCpuUsage", {
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
@@ -119,8 +136,19 @@ export default function useStats(stats: FrigateStats | undefined) {
}
});
// Add message if debug replay is active
if (debugReplayStatus?.active) {
problems.push({
text: t("stats.debugReplayActive", {
defaultValue: "Debug replay session is active",
}),
color: "text-selected",
relevantLink: "/replay",
});
}
return problems;
}, [config, memoizedStats, t]);
}, [config, memoizedStats, t, debugReplayStatus]);
return { potentialProblems };
}
+99
View File
@@ -0,0 +1,99 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useWsMessageSubscribe, WsFeedMessage } from "@/api/ws";
import { extractCameraName } from "@/utils/wsUtil";
type UseWsMessageBufferReturn = {
messages: WsFeedMessage[];
clear: () => void;
};
type MessageFilter = {
cameraFilter?: string | string[]; // "all", specific camera name, or array of camera names (undefined in array = all)
};
export function useWsMessageBuffer(
maxSize: number = 2000,
paused: boolean = false,
filter?: MessageFilter,
): UseWsMessageBufferReturn {
const bufferRef = useRef<WsFeedMessage[]>([]);
const [version, setVersion] = useState(0);
const pausedRef = useRef(paused);
const filterRef = useRef(filter);
pausedRef.current = paused;
filterRef.current = filter;
const batchTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
const dirtyRef = useRef(false);
useEffect(() => {
batchTimerRef.current = setInterval(() => {
if (dirtyRef.current) {
dirtyRef.current = false;
setVersion((v) => v + 1);
}
}, 200);
return () => {
if (batchTimerRef.current) {
clearInterval(batchTimerRef.current);
}
};
}, []);
const shouldIncludeMessage = useCallback((msg: WsFeedMessage): boolean => {
const currentFilter = filterRef.current;
if (!currentFilter) return true;
// Check camera filter
const cf = currentFilter.cameraFilter;
if (cf !== undefined) {
if (Array.isArray(cf)) {
// Array of cameras: include messages matching any camera in the list
const msgCamera = extractCameraName(msg);
if (msgCamera && !cf.includes(msgCamera)) {
return false;
}
} else if (cf !== "all") {
// Single string camera filter
const msgCamera = extractCameraName(msg);
if (msgCamera !== cf) {
return false;
}
}
}
return true;
}, []);
useWsMessageSubscribe(
useCallback(
(msg: WsFeedMessage) => {
if (pausedRef.current) return;
if (!shouldIncludeMessage(msg)) return;
const buf = bufferRef.current;
buf.push(msg);
if (buf.length > maxSize) {
buf.splice(0, buf.length - maxSize);
}
dirtyRef.current = true;
},
[shouldIncludeMessage, maxSize],
),
);
const clear = useCallback(() => {
bufferRef.current = [];
setVersion((v) => v + 1);
}, []);
// version is used to trigger re-renders; we spread the buffer
// into a new array so that downstream useMemo dependencies
// see a new reference and recompute.
// eslint-disable-next-line react-hooks/exhaustive-deps
const messages = useMemo(() => [...bufferRef.current], [version]);
return { messages, clear };
}
+1 -1
View File
@@ -642,4 +642,4 @@ function CaseAssignmentDialog({
);
}
export default Exports;
export default Exports;
+119 -99
View File
@@ -35,10 +35,12 @@ import { isIOS, isMobile } from "react-device-detect";
import { isPWA } from "@/utils/isPWA";
import { isInIframe } from "@/utils/isIFrame";
import { useTranslation } from "react-i18next";
import WsMessageFeed from "@/components/ws/WsMessageFeed";
function Logs() {
const { t } = useTranslation(["views/system"]);
const [logService, setLogService] = useState<LogType>("frigate");
const isWebsocket = logService === "websocket";
const tabsRef = useRef<HTMLDivElement | null>(null);
const lazyLogWrapperRef = useRef<HTMLDivElement>(null);
const [logs, setLogs] = useState<string[]>([]);
@@ -216,6 +218,12 @@ function Logs() {
}, [logService, filterSeverity, t]);
useEffect(() => {
if (isWebsocket) {
setIsLoading(false);
setLogs([]);
return;
}
setIsLoading(true);
setLogs([]);
lastFetchedIndexRef.current = -1;
@@ -494,116 +502,128 @@ function Logs() {
data-nav-item={item}
aria-label={`Select ${item}`}
>
<div className="smart-capitalize">{item}</div>
<div
className={item !== "websocket" ? "smart-capitalize" : ""}
>
{item === "websocket" ? t("logs.websocket.label") : item}
</div>
</ToggleGroupItem>
))}
</ToggleGroup>
<ScrollBar orientation="horizontal" className="h-0" />
</div>
</ScrollArea>
<div className="flex items-center gap-2">
<Button
className="flex items-center justify-between gap-2"
aria-label={t("logs.copy.label")}
size="sm"
onClick={handleCopyLogs}
>
<FaCopy className="text-secondary-foreground" />
<div className="hidden text-primary md:block">
{t("logs.copy.label")}
</div>
</Button>
<Button
className="flex items-center justify-between gap-2"
aria-label={t("logs.download.label")}
size="sm"
onClick={handleDownloadLogs}
>
<FaDownload className="text-secondary-foreground" />
<div className="hidden text-primary md:block">
{t("button.download", { ns: "common" })}
</div>
</Button>
<LogSettingsButton
selectedLabels={filterSeverity}
updateLabelFilter={setFilterSeverity}
logSettings={logSettings}
setLogSettings={setLogSettings}
/>
</div>
</div>
<div className="relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt font-mono text-xs sm:p-1">
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
<div className="col-span-3 lg:col-span-2">
<div className="flex w-full flex-row items-center">
<div className="ml-1 min-w-16 smart-capitalize lg:min-w-20">
{t("logs.type.label")}
{!isWebsocket && (
<div className="flex items-center gap-2">
<Button
className="flex items-center justify-between gap-2"
aria-label={t("logs.copy.label")}
size="sm"
onClick={handleCopyLogs}
>
<FaCopy className="text-secondary-foreground" />
<div className="hidden text-primary md:block">
{t("logs.copy.label")}
</div>
<div className="mr-3">{t("logs.type.timestamp")}</div>
</Button>
<Button
className="flex items-center justify-between gap-2"
aria-label={t("logs.download.label")}
size="sm"
onClick={handleDownloadLogs}
>
<FaDownload className="text-secondary-foreground" />
<div className="hidden text-primary md:block">
{t("button.download", { ns: "common" })}
</div>
</Button>
<LogSettingsButton
selectedLabels={filterSeverity}
updateLabelFilter={setFilterSeverity}
logSettings={logSettings}
setLogSettings={setLogSettings}
/>
</div>
)}
</div>
{isWebsocket ? (
<div className="my-2 flex size-full flex-col overflow-hidden rounded-md border border-secondary bg-background_alt">
<WsMessageFeed maxSize={2000} />
</div>
) : (
<div className="relative my-2 flex size-full flex-col overflow-hidden whitespace-pre-wrap rounded-md border border-secondary bg-background_alt font-mono text-xs sm:p-1">
<div className="grid grid-cols-5 *:px-0 *:py-3 *:text-sm *:text-primary/40 md:grid-cols-12">
<div className="col-span-3 lg:col-span-2">
<div className="flex w-full flex-row items-center">
<div className="ml-1 min-w-16 smart-capitalize lg:min-w-20">
{t("logs.type.label")}
</div>
<div className="mr-3">{t("logs.type.timestamp")}</div>
</div>
</div>
<div
className={cn(
"flex items-center",
logService == "frigate" ? "col-span-2" : "col-span-1",
)}
>
{t("logs.type.tag")}
</div>
<div
className={cn(
"col-span-5 flex items-center",
logService == "frigate"
? "md:col-span-7 lg:col-span-8"
: "md:col-span-8 lg:col-span-9",
)}
>
<div className="flex flex-1">{t("logs.type.message")}</div>
</div>
</div>
<div
className={cn(
"flex items-center",
logService == "frigate" ? "col-span-2" : "col-span-1",
)}
>
{t("logs.type.tag")}
</div>
<div
className={cn(
"col-span-5 flex items-center",
logService == "frigate"
? "md:col-span-7 lg:col-span-8"
: "md:col-span-8 lg:col-span-9",
)}
>
<div className="flex flex-1">{t("logs.type.message")}</div>
</div>
</div>
<div ref={lazyLogWrapperRef} className="size-full">
{isLoading ? (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
) : (
<EnhancedScrollFollow
startFollowing={!isLoading}
onCustomScroll={handleScroll}
render={({ follow, onScroll }) => (
<>
{follow && !logSettings.disableStreaming && (
<div className="absolute right-1 top-3">
<Tooltip>
<TooltipTrigger>
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
</TooltipTrigger>
<TooltipContent>{t("logs.tips")}</TooltipContent>
</Tooltip>
</div>
)}
<LazyLog
ref={lazyLogRef}
enableLineNumbers={false}
selectableLines
lineClassName="text-primary bg-background"
highlightLineClassName="bg-primary/20"
onRowClick={handleRowClick}
formatPart={formatPart}
text={logs.join("\n")}
follow={follow}
onScroll={onScroll}
loadingComponent={
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
}
loading={isLoading}
/>
</>
)}
/>
)}
<div ref={lazyLogWrapperRef} className="size-full">
{isLoading ? (
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
) : (
<EnhancedScrollFollow
startFollowing={!isLoading}
onCustomScroll={handleScroll}
render={({ follow, onScroll }) => (
<>
{follow && !logSettings.disableStreaming && (
<div className="absolute right-1 top-3">
<Tooltip>
<TooltipTrigger>
<MdCircle className="mr-2 size-2 animate-pulse cursor-default text-selected shadow-selected drop-shadow-md" />
</TooltipTrigger>
<TooltipContent>{t("logs.tips")}</TooltipContent>
</Tooltip>
</div>
)}
<LazyLog
ref={lazyLogRef}
enableLineNumbers={false}
selectableLines
lineClassName="text-primary bg-background"
highlightLineClassName="bg-primary/20"
onRowClick={handleRowClick}
formatPart={formatPart}
text={logs.join("\n")}
follow={follow}
onScroll={onScroll}
loadingComponent={
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
}
loading={isLoading}
/>
</>
)}
/>
)}
</div>
</div>
</div>
)}
</div>
);
}
+725
View File
@@ -0,0 +1,725 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link, useNavigate } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import AutoUpdatingCameraImage from "@/components/camera/AutoUpdatingCameraImage";
import { Button, buttonVariants } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Label } from "@/components/ui/label";
import { Switch } from "@/components/ui/switch";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from "@/components/ui/alert-dialog";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import { useCameraActivity } from "@/hooks/use-camera-activity";
import { cn } from "@/lib/utils";
import Heading from "@/components/ui/heading";
import { Toaster } from "@/components/ui/sonner";
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
import { getIconForLabel } from "@/utils/iconUtil";
import { getTranslatedLabel } from "@/utils/i18n";
import { ObjectType } from "@/types/ws";
import WsMessageFeed from "@/components/ws/WsMessageFeed";
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
import { LuExternalLink, LuInfo, LuSettings } from "react-icons/lu";
import { LuSquare } from "react-icons/lu";
import { MdReplay } from "react-icons/md";
import { isDesktop, isMobile } from "react-device-detect";
import Logo from "@/components/Logo";
import { Separator } from "@/components/ui/separator";
import { useDocDomain } from "@/hooks/use-doc-domain";
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
import { IoMdArrowRoundBack } from "react-icons/io";
type DebugReplayStatus = {
active: boolean;
replay_camera: string | null;
source_camera: string | null;
start_time: number | null;
end_time: number | null;
live_ready: boolean;
};
type DebugOptions = {
bbox: boolean;
timestamp: boolean;
zones: boolean;
mask: boolean;
motion: boolean;
regions: boolean;
paths: boolean;
};
const DEFAULT_OPTIONS: DebugOptions = {
bbox: true,
timestamp: false,
zones: false,
mask: false,
motion: true,
regions: false,
paths: false,
};
const DEBUG_OPTION_KEYS: (keyof DebugOptions)[] = [
"bbox",
"timestamp",
"zones",
"mask",
"motion",
"regions",
"paths",
];
const DEBUG_OPTION_I18N_KEY: Record<keyof DebugOptions, string> = {
bbox: "boundingBoxes",
timestamp: "timestamp",
zones: "zones",
mask: "mask",
motion: "motion",
regions: "regions",
paths: "paths",
};
const REPLAY_INIT_SKELETON_TIMEOUT_MS = 8000;
export default function Replay() {
const { t } = useTranslation(["views/replay", "views/settings", "common"]);
const navigate = useNavigate();
const { getLocaleDocUrl } = useDocDomain();
const {
data: status,
mutate: refreshStatus,
isLoading,
} = useSWR<DebugReplayStatus>("debug_replay/status", {
refreshInterval: 1000,
});
const [isInitializing, setIsInitializing] = useState(true);
// Refresh status immediately on mount to avoid showing "no session" briefly
useEffect(() => {
const initializeStatus = async () => {
await refreshStatus();
setIsInitializing(false);
};
initializeStatus();
}, [refreshStatus]);
useEffect(() => {
if (status?.live_ready) {
setShowReplayInitSkeleton(false);
}
}, [status?.live_ready]);
const [options, setOptions] = useState<DebugOptions>(DEFAULT_OPTIONS);
const [isStopping, setIsStopping] = useState(false);
const [configDialogOpen, setConfigDialogOpen] = useState(false);
const searchParams = useMemo(() => {
const params = new URLSearchParams();
for (const key of DEBUG_OPTION_KEYS) {
params.set(key, options[key] ? "1" : "0");
}
return params;
}, [options]);
const handleSetOption = useCallback(
(key: keyof DebugOptions, value: boolean) => {
setOptions((prev) => ({ ...prev, [key]: value }));
},
[],
);
const handleStop = useCallback(() => {
setIsStopping(true);
axios
.post("debug_replay/stop")
.then(() => {
toast.success(t("dialog.toast.stopped"), {
position: "top-center",
});
refreshStatus();
navigate("/review");
})
.catch((error) => {
const errorMessage =
error.response?.data?.message ||
error.response?.data?.detail ||
"Unknown error";
toast.error(t("dialog.toast.stopError", { error: errorMessage }), {
position: "top-center",
});
})
.finally(() => {
setIsStopping(false);
});
}, [navigate, refreshStatus, t]);
// Camera activity for the replay camera
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const replayCameraName = status?.replay_camera ?? "";
const replayCameraConfig = replayCameraName
? config?.cameras?.[replayCameraName]
: undefined;
const { objects } = useCameraActivity(replayCameraConfig);
const [showReplayInitSkeleton, setShowReplayInitSkeleton] = useState(false);
// debug draw
const containerRef = useRef<HTMLDivElement>(null);
const [debugDraw, setDebugDraw] = useState(false);
useEffect(() => {
if (!status?.active || !status.replay_camera) {
setShowReplayInitSkeleton(false);
return;
}
setShowReplayInitSkeleton(true);
const timeout = window.setTimeout(() => {
setShowReplayInitSkeleton(false);
}, REPLAY_INIT_SKELETON_TIMEOUT_MS);
return () => {
window.clearTimeout(timeout);
};
}, [status?.active, status?.replay_camera]);
useEffect(() => {
if (status?.live_ready) {
setShowReplayInitSkeleton(false);
}
}, [status?.live_ready]);
// Format time range for display
const timeRangeDisplay = useMemo(() => {
if (!status?.start_time || !status?.end_time) return "";
const start = new Date(status.start_time * 1000).toLocaleString();
const end = new Date(status.end_time * 1000).toLocaleString();
return `${start}${end}`;
}, [status]);
// Show loading state
if (isInitializing || (isLoading && !status?.active)) {
return (
<div className="flex size-full items-center justify-center">
<ActivityIndicator />
</div>
);
}
// No active session
if (!status?.active) {
return (
<div className="flex size-full flex-col items-center justify-center gap-4 p-8">
<MdReplay className="size-12" />
<Heading as="h2" className="text-center">
{t("page.noSession")}
</Heading>
<p className="max-w-md text-center text-muted-foreground">
{t("page.noSessionDesc")}
</p>
<Button variant="default" onClick={() => navigate("/review")}>
{t("page.goToRecordings")}
</Button>
</div>
);
}
return (
<div className="flex size-full flex-col overflow-hidden">
<Toaster position="top-center" closeButton={true} />
{/* Top bar */}
<div className="flex min-h-12 items-center justify-between border-b border-secondary px-2 py-2 md:min-h-16 md:px-3 md:py-3">
{isMobile && (
<Logo className="absolute inset-x-1/2 h-8 -translate-x-1/2" />
)}
<Button
className="flex items-center gap-2.5 rounded-lg"
aria-label={t("label.back", { ns: "common" })}
size="sm"
onClick={() => navigate(-1)}
>
<IoMdArrowRoundBack className="size-5 text-secondary-foreground" />
{isDesktop && (
<div className="text-primary">
{t("button.back", { ns: "common" })}
</div>
)}
</Button>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
className="flex items-center gap-2"
onClick={() => setConfigDialogOpen(true)}
>
<LuSettings className="size-4" />
<span className="hidden md:inline">{t("page.configuration")}</span>
</Button>
<AlertDialog>
<AlertDialogTrigger asChild>
<Button
variant="destructive"
size="sm"
className="flex items-center gap-2 text-white"
disabled={isStopping}
>
{isStopping && <ActivityIndicator className="size-4" />}
<span className="hidden md:inline">{t("page.stopReplay")}</span>
<LuSquare className="size-4 md:hidden" />
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("page.confirmStop.title")}
</AlertDialogTitle>
<AlertDialogDescription>
{t("page.confirmStop.description")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>
{t("page.confirmStop.cancel")}
</AlertDialogCancel>
<AlertDialogAction
onClick={handleStop}
className={cn(
buttonVariants({ variant: "destructive" }),
"text-white",
)}
>
{t("page.confirmStop.confirm")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</div>
{/* Main content */}
<div className="flex flex-1 flex-col overflow-hidden pb-2 md:flex-row">
{/* Camera feed */}
<div className="flex max-h-[40%] px-2 pt-2 md:h-dvh md:max-h-full md:w-7/12 md:grow md:px-4 md:pt-2">
{isStopping ? (
<div className="flex size-full items-center justify-center rounded-lg bg-background_alt">
<div className="flex flex-col items-center justify-center gap-2">
<ActivityIndicator className="size-8" />
<div className="text-secondary-foreground">
{t("page.stoppingReplay")}
</div>
</div>
</div>
) : (
status.replay_camera && (
<div className="relative size-full min-h-10" ref={containerRef}>
<AutoUpdatingCameraImage
className="size-full"
cameraClasses="relative w-full h-full flex flex-col justify-start"
searchParams={searchParams}
camera={status.replay_camera}
showFps={false}
/>
{debugDraw && (
<DebugDrawingLayer
containerRef={containerRef}
cameraWidth={
config?.cameras?.[status.source_camera ?? ""]?.detect
.width ?? 1280
}
cameraHeight={
config?.cameras?.[status.source_camera ?? ""]?.detect
.height ?? 720
}
/>
)}
{showReplayInitSkeleton && (
<div className="pointer-events-none absolute inset-0 z-10 size-full rounded-lg bg-background">
<Skeleton className="size-full rounded-lg" />
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center gap-2">
<ActivityIndicator className="size-8" />
<div className="text-secondary-foreground">
{t("page.initializingReplay")}
</div>
</div>
</div>
)}
</div>
)
)}
</div>
{/* Side panel */}
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto rounded-lg border-[1px] border-secondary-foreground bg-background_alt p-2 md:order-none md:mb-0 md:mr-2 md:mt-0 md:w-4/12">
<div className="mb-5 flex flex-col space-y-2">
<Heading as="h3" className="mb-0">
{t("title")}
</Heading>
<div className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="smart-capitalize">{status.source_camera}</span>
{timeRangeDisplay && (
<>
<span className="hidden md:inline"></span>
<span className="hidden md:inline">{timeRangeDisplay}</span>
</>
)}
</div>
<div className="mb-5 space-y-3 text-sm text-muted-foreground">
<p>{t("description")}</p>
</div>
</div>
<Tabs defaultValue="debug" className="flex h-full w-full flex-col">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="debug">
{t("debug.debugging", { ns: "views/settings" })}
</TabsTrigger>
<TabsTrigger value="objects">{t("page.objects")}</TabsTrigger>
<TabsTrigger value="messages">
{t("websocket_messages")}
</TabsTrigger>
</TabsList>
<TabsContent value="debug" className="mt-2">
<div className="mt-2 space-y-6">
<div className="my-2.5 flex flex-col gap-2.5">
{DEBUG_OPTION_KEYS.map((key) => {
const i18nKey = DEBUG_OPTION_I18N_KEY[key];
return (
<div
key={key}
className="flex w-full flex-row items-center justify-between"
>
<div className="mb-1 flex flex-col">
<div className="flex items-center gap-2">
<Label
className="mb-0 cursor-pointer text-primary smart-capitalize"
htmlFor={`debug-${key}`}
>
{t(`debug.${i18nKey}.title`, {
ns: "views/settings",
})}
</Label>
{(key === "bbox" ||
key === "motion" ||
key === "regions" ||
key === "paths") && (
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm">
{key === "bbox" ? (
<>
<p className="mb-2">
<strong>
{t(
"debug.boundingBoxes.colors.label",
{
ns: "views/settings",
},
)}
</strong>
</p>
<ul className="list-disc space-y-1 pl-5">
<Trans ns="views/settings">
debug.boundingBoxes.colors.info
</Trans>
</ul>
</>
) : (
<Trans ns="views/settings">
{`debug.${i18nKey}.tips`}
</Trans>
)}
</PopoverContent>
</Popover>
)}
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t(`debug.${i18nKey}.desc`, {
ns: "views/settings",
})}
</div>
</div>
<Switch
id={`debug-${key}`}
className="ml-1"
checked={options[key]}
onCheckedChange={(checked) =>
handleSetOption(key, checked)
}
/>
</div>
);
})}
{isDesktop && (
<>
<Separator className="my-2" />
<div className="flex w-full flex-row items-center justify-between">
<div className="mb-2 flex flex-col">
<div className="flex items-center gap-2">
<Label
className="mb-0 cursor-pointer text-primary smart-capitalize"
htmlFor="debugdraw"
>
{t("debug.objectShapeFilterDrawing.title", {
ns: "views/settings",
})}
</Label>
<Popover>
<PopoverTrigger asChild>
<div className="cursor-pointer p-0">
<LuInfo className="size-4" />
<span className="sr-only">
{t("button.info", { ns: "common" })}
</span>
</div>
</PopoverTrigger>
<PopoverContent className="w-80 text-sm">
{t("debug.objectShapeFilterDrawing.tips", {
ns: "views/settings",
})}
<div className="mt-2 flex items-center text-primary">
<Link
to={getLocaleDocUrl(
"configuration/object_filters#object-shape",
)}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", {
ns: "common",
})}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</PopoverContent>
</Popover>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{t("debug.objectShapeFilterDrawing.desc", {
ns: "views/settings",
})}
</div>
</div>
<Switch
key={"draw"}
className="ml-1"
id="debug_draw"
checked={debugDraw}
onCheckedChange={(isChecked) => {
setDebugDraw(isChecked);
}}
/>
</div>
</>
)}
</div>
</div>
</TabsContent>
<TabsContent value="objects" className="mt-2">
<ObjectList
cameraConfig={replayCameraConfig}
objects={objects}
config={config}
/>
</TabsContent>
<TabsContent
value="messages"
className="mt-2 flex min-h-0 flex-1 flex-col"
>
<div className="flex h-full flex-col overflow-hidden rounded-md border border-secondary">
<WsMessageFeed
maxSize={2000}
lockedCamera={status.replay_camera ?? undefined}
showCameraBadge={false}
/>
</div>
</TabsContent>
</Tabs>
</div>
</div>
<Dialog open={configDialogOpen} onOpenChange={setConfigDialogOpen}>
<DialogContent className="scrollbar-container max-h-[90dvh] overflow-y-auto sm:max-w-xl md:max-w-3xl lg:max-w-4xl">
<DialogHeader>
<DialogTitle>{t("page.configuration")}</DialogTitle>
<DialogDescription className="mb-5">
{t("page.configurationDesc")}
</DialogDescription>
</DialogHeader>
<div className="space-y-6">
<ConfigSectionTemplate
sectionKey="motion"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
<ConfigSectionTemplate
sectionKey="objects"
level="replay"
cameraName={status.replay_camera ?? undefined}
skipSave
noStickyButtons
requiresRestart={false}
collapsible
defaultCollapsed={false}
showTitle
showOverrideIndicator={false}
/>
</div>
</DialogContent>
</Dialog>
</div>
);
}
type ObjectListProps = {
cameraConfig?: CameraConfig;
objects?: ObjectType[];
config?: FrigateConfig;
};
function ObjectList({ cameraConfig, objects, config }: ObjectListProps) {
const { t } = useTranslation(["views/settings"]);
const colormap = useMemo(() => {
if (!config) {
return;
}
return config.model?.colormap;
}, [config]);
const getColorForObjectName = useCallback(
(objectName: string) => {
return colormap && colormap[objectName]
? `rgb(${colormap[objectName][2]}, ${colormap[objectName][1]}, ${colormap[objectName][0]})`
: "rgb(128, 128, 128)";
},
[colormap],
);
if (!objects || objects.length === 0) {
return (
<div className="p-3 text-center text-sm text-muted-foreground">
{t("debug.noObjects", { ns: "views/settings" })}
</div>
);
}
return (
<div className="flex w-full flex-col gap-2">
{objects.map((obj: ObjectType) => {
return (
<div
key={obj.id}
className="flex flex-col rounded-lg bg-secondary/30 p-2"
>
<div className="flex flex-row items-center gap-3 pb-1">
<div
className="rounded-lg p-2"
style={{
backgroundColor: obj.stationary
? "rgb(110,110,110)"
: getColorForObjectName(obj.label),
}}
>
{getIconForLabel(obj.label, "object", "size-4 text-white")}
</div>
<div className="text-sm font-medium">
{getTranslatedLabel(obj.label)}
</div>
</div>
<div className="flex flex-col gap-1 pl-1 text-xs text-primary-variant">
<div className="flex items-center justify-between">
<span>
{t("debug.objectShapeFilterDrawing.score", {
ns: "views/settings",
})}
:
</span>
<span className="text-primary">
{obj.score ? (obj.score * 100).toFixed(1) : "-"}%
</span>
</div>
{obj.ratio && (
<div className="flex items-center justify-between">
<span>
{t("debug.objectShapeFilterDrawing.ratio", {
ns: "views/settings",
})}
:
</span>
<span className="text-primary">{obj.ratio.toFixed(2)}</span>
</div>
)}
{obj.area && cameraConfig && (
<div className="flex items-center justify-between">
<span>
{t("debug.objectShapeFilterDrawing.area", {
ns: "views/settings",
})}
:
</span>
<span className="text-primary">
{obj.area} px (
{(
(obj.area /
(cameraConfig.detect.width *
cameraConfig.detect.height)) *
100
).toFixed(2)}
%)
</span>
</div>
)}
</div>
</div>
);
})}
</div>
);
}
+1 -1
View File
@@ -12,7 +12,7 @@ export type LogLine = {
content: string;
};
export const logTypes = ["frigate", "go2rtc", "nginx"] as const;
export const logTypes = ["frigate", "go2rtc", "nginx", "websocket"] as const;
export type LogType = (typeof logTypes)[number];
export type LogSettingsType = {
+12
View File
@@ -148,3 +148,15 @@ export function detectCameraAudioFeatures(
audioOutput: !!audioOutput,
};
}
const REPLAY_CAMERA_PREFIX = "_replay_";
/**
* Check if a camera name is a debug replay camera.
*
* @param name - The camera name to check
* @returns true if the camera is a replay camera
*/
export function isReplayCamera(name: string): boolean {
return name.startsWith(REPLAY_CAMERA_PREFIX);
}
+7 -2
View File
@@ -514,13 +514,18 @@ const mergeSectionConfig = (
export function getSectionConfig(
sectionKey: string,
level: "global" | "camera",
level: "global" | "camera" | "replay",
): SectionConfig {
const entry = sectionConfigs[sectionKey];
if (!entry) {
return {};
}
const overrides = level === "global" ? entry.global : entry.camera;
const overrides =
level === "global"
? entry.global
: level === "replay"
? entry.replay
: entry.camera;
return mergeSectionConfig(entry.base, overrides);
}
+53
View File
@@ -0,0 +1,53 @@
import { WsFeedMessage } from "@/api/ws";
const EVENT_TOPICS = new Set([
"events",
"reviews",
"tracked_object_update",
"triggers",
]);
const SYSTEM_TOPICS = new Set([
"stats",
"model_state",
"job_state",
"embeddings_reindex_progress",
"audio_transcription_state",
"birdseye_layout",
]);
export function extractCameraName(message: WsFeedMessage): string | null {
// Try extracting from topic pattern: {camera}/motion, {camera}/audio/rms, etc.
const topicParts = message.topic.split("/");
if (
topicParts.length >= 2 &&
!EVENT_TOPICS.has(message.topic) &&
!SYSTEM_TOPICS.has(message.topic) &&
message.topic !== "camera_activity" &&
message.topic !== "audio_detections" &&
message.topic !== "restart" &&
message.topic !== "notification_test"
) {
return topicParts[0];
}
// Try extracting from payload
try {
const data =
typeof message.payload === "string"
? JSON.parse(message.payload)
: message.payload;
if (typeof data === "object" && data !== null) {
if ("camera" in data) return data.camera as string;
if ("after" in data && data.after?.camera)
return data.after.camera as string;
if ("before" in data && data.before?.camera)
return data.before.camera as string;
}
} catch {
// ignore parse errors
}
return null;
}
+80 -7
View File
@@ -1,6 +1,8 @@
import ReviewCard from "@/components/card/ReviewCard";
import ReviewFilterGroup from "@/components/filter/ReviewFilterGroup";
import DebugReplayDialog from "@/components/overlay/DebugReplayDialog";
import ExportDialog from "@/components/overlay/ExportDialog";
import ActionsDropdown from "@/components/overlay/ActionsDropdown";
import PreviewPlayer, {
PreviewController,
} from "@/components/player/PreviewPlayer";
@@ -199,6 +201,11 @@ export function RecordingView({
const [exportRange, setExportRange] = useState<TimeRange>();
const [showExportPreview, setShowExportPreview] = useState(false);
// debug replay
const [debugReplayMode, setDebugReplayMode] = useState<ExportMode>("none");
const [debugReplayRange, setDebugReplayRange] = useState<TimeRange>();
// move to next clip
const onClipEnded = useCallback(() => {
@@ -269,7 +276,7 @@ export function RecordingView({
);
useEffect(() => {
if (scrubbing || exportRange) {
if (scrubbing || exportRange || debugReplayRange) {
if (
currentTime > currentTimeRange.before + 60 ||
currentTime < currentTimeRange.after - 60
@@ -591,6 +598,23 @@ export function RecordingView({
selected={mainCamera}
onSelectCamera={onSelectCamera}
/>
{isDesktop && (
<DebugReplayDialog
camera={mainCamera}
currentTime={currentTime}
latestTime={timeRange.before}
mode={debugReplayMode}
range={debugReplayRange}
setRange={(range: TimeRange | undefined) => {
setDebugReplayRange(range);
if (range != undefined) {
mainControllerRef.current?.pause();
}
}}
setMode={setDebugReplayMode}
/>
)}
{isDesktop && (
<ExportDialog
camera={mainCamera}
@@ -639,6 +663,28 @@ export function RecordingView({
setMotionOnly={() => {}}
/>
)}
{isDesktop && (
<ActionsDropdown
onDebugReplayClick={() => {
const now = new Date(timeRange.before * 1000);
now.setHours(now.getHours() - 1);
setDebugReplayRange({
after: now.getTime() / 1000,
before: timeRange.before,
});
setDebugReplayMode("select");
}}
onExportClick={() => {
const now = new Date(timeRange.before * 1000);
now.setHours(now.getHours() - 1);
setExportRange({
before: timeRange.before,
after: now.getTime() / 1000,
});
setExportMode("select");
}}
/>
)}
{isDesktop ? (
<ToggleGroup
className="*:rounded-md *:px-3 *:py-4"
@@ -688,6 +734,16 @@ export function RecordingView({
showExportPreview={showExportPreview}
allLabels={reviewFilterList.labels}
allZones={reviewFilterList.zones}
debugReplayMode={debugReplayMode}
debugReplayRange={debugReplayRange}
setDebugReplayMode={setDebugReplayMode}
setDebugReplayRange={(range: TimeRange | undefined) => {
setDebugReplayRange(range);
if (range != undefined) {
mainControllerRef.current?.pause();
}
}}
onUpdateFilter={updateFilter}
setRange={setExportRange}
setMode={setExportMode}
@@ -758,7 +814,9 @@ export function RecordingView({
timeRange={currentTimeRange}
cameraPreviews={allPreviews ?? []}
startTimestamp={playbackStart}
hotKeys={exportMode != "select"}
hotKeys={
exportMode != "select" && debugReplayMode != "select"
}
fullscreen={fullscreen}
onTimestampUpdate={(timestamp) => {
setPlayerTime(timestamp);
@@ -772,7 +830,11 @@ export function RecordingView({
onControllerReady={(controller) => {
mainControllerRef.current = controller;
}}
isScrubbing={scrubbing || exportMode == "timeline"}
isScrubbing={
scrubbing ||
exportMode == "timeline" ||
debugReplayMode == "timeline"
}
supportsFullscreen={supportsFullScreen}
setFullResolution={setFullResolution}
toggleFullscreen={toggleFullscreen}
@@ -840,18 +902,29 @@ export function RecordingView({
contentRef={contentRef}
mainCamera={mainCamera}
timelineType={
(exportRange == undefined ? timelineType : "timeline") ??
"timeline"
(exportRange == undefined && debugReplayRange == undefined
? timelineType
: "timeline") ?? "timeline"
}
timeRange={timeRange}
mainCameraReviewItems={mainCameraReviewItems}
activeReviewItem={activeReviewItem}
currentTime={currentTime}
exportRange={exportMode == "timeline" ? exportRange : undefined}
exportRange={
exportMode == "timeline"
? exportRange
: debugReplayMode == "timeline"
? debugReplayRange
: undefined
}
setCurrentTime={setCurrentTime}
manuallySetCurrentTime={manuallySetCurrentTime}
setScrubbing={setScrubbing}
setExportRange={setExportRange}
setExportRange={
debugReplayMode == "timeline"
? setDebugReplayRange
: setExportRange
}
onAnalysisOpen={onAnalysisOpen}
isPlaying={mainControllerRef?.current?.isPlaying() ?? false}
/>