mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-09-27 01:48:58 +03:00
Miscellaneous fixes (#23238)
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
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
CI / AMD64 Build (push) Waiting to run
* start audio transcription post processor when enabled on any camera * Fetch embed key whenever an error occurs in case the llama server was restarted * mypy * add tooltips for colored dots in settings menu * add ability to reorder cameras from management pane * add ability to reorder birdseye * add reordering save text to camera management view * Include NPU in latency performance hint * Implement turbo for NPU on object detection * hide order fields * drop auto-derived field paths from camera value when unset globally * use correct field type for export hwaccel args * add debug replay to detail actions menu * clarify debug replay in docs * guard get_current_frame_time against missing camera state * Implement debug reply from export * Refactor debug replay to use sources for dynamic playback * Mypy * fix debug export replay source timestamp handling * skip replay cameras in stats immediately * broadcast debug replay state over ws and buffer pre-OPEN sends - push debug replay session state over the job_state ws topic so the status bar reacts instantly to start/stop without polling - fix child-effect-before-parent-effect race in WsProvider that silently dropped initial snapshot requests on cold load * fix debug replay test hang --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
co-authored by
Nicolas Mowen
parent
d968f00500
commit
43d97acd21
@@ -10,15 +10,21 @@ export function WsProvider({ children }: { children: ReactNode }) {
|
||||
const reconnectTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const reconnectAttempt = useRef(0);
|
||||
const unmounted = useRef(false);
|
||||
const pendingSends = useRef<Map<string, unknown>>(new Map());
|
||||
|
||||
const sendJsonMessage = useCallback((msg: unknown) => {
|
||||
if (wsRef.current?.readyState === WebSocket.OPEN) {
|
||||
wsRef.current.send(JSON.stringify(msg));
|
||||
} else if (msg && typeof msg === "object" && "topic" in msg) {
|
||||
// Sends issued before the socket reaches OPEN (or during a reconnect
|
||||
// window) are buffered here and flushed in onopen
|
||||
pendingSends.current.set(String((msg as { topic: unknown }).topic), msg);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
unmounted.current = false;
|
||||
const queue = pendingSends.current;
|
||||
|
||||
function connect() {
|
||||
if (unmounted.current) return;
|
||||
@@ -31,6 +37,10 @@ export function WsProvider({ children }: { children: ReactNode }) {
|
||||
ws.send(
|
||||
JSON.stringify({ topic: "onConnect", message: "", retain: false }),
|
||||
);
|
||||
for (const queued of queue.values()) {
|
||||
ws.send(JSON.stringify(queued));
|
||||
}
|
||||
queue.clear();
|
||||
};
|
||||
|
||||
ws.onmessage = (event: MessageEvent) => {
|
||||
@@ -64,6 +74,7 @@ export function WsProvider({ children }: { children: ReactNode }) {
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
}
|
||||
queue.clear();
|
||||
resetWsStore();
|
||||
};
|
||||
}, [wsUrl]);
|
||||
|
||||
@@ -32,6 +32,9 @@ import { FaFolder, FaVideo } from "react-icons/fa";
|
||||
import { HiSquare2Stack } from "react-icons/hi2";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import useContextMenu from "@/hooks/use-contextmenu";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
type CaseCardProps = {
|
||||
className: string;
|
||||
@@ -123,11 +126,63 @@ export function ExportCard({
|
||||
onAssignToCase,
|
||||
onRemoveFromCase,
|
||||
}: ExportCardProps) {
|
||||
const { t } = useTranslation(["views/exports"]);
|
||||
const { t } = useTranslation(["views/exports", "views/replay"]);
|
||||
const navigate = useNavigate();
|
||||
const isAdmin = useIsAdmin();
|
||||
const [loading, setLoading] = useState(
|
||||
exportedRecording.thumb_path.length > 0,
|
||||
);
|
||||
const [isStartingReplay, setIsStartingReplay] = useState(false);
|
||||
|
||||
const handleDebugReplay = useCallback(() => {
|
||||
setIsStartingReplay(true);
|
||||
|
||||
axios
|
||||
.post("debug_replay/start_from_export", {
|
||||
export_id: exportedRecording.id,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
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={`${baseUrl}replay`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Button>
|
||||
{t("dialog.toast.goToReplay", { ns: "views/replay" })}
|
||||
</Button>
|
||||
</a>
|
||||
),
|
||||
});
|
||||
} else {
|
||||
toast.error(
|
||||
t("dialog.toast.error", {
|
||||
ns: "views/replay",
|
||||
error: errorMessage,
|
||||
}),
|
||||
{ position: "top-center" },
|
||||
);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
setIsStartingReplay(false);
|
||||
});
|
||||
}, [exportedRecording.id, navigate, t]);
|
||||
|
||||
// Resync the skeleton state whenever the backing export changes. The
|
||||
// list keys by id now, so in practice the component remounts instead
|
||||
@@ -301,6 +356,21 @@ export function ExportCard({
|
||||
{t("tooltip.downloadVideo")}
|
||||
</a>
|
||||
</DropdownMenuItem>
|
||||
{isAdmin && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("title", { ns: "views/replay" })}
|
||||
disabled={isStartingReplay}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDebugReplay();
|
||||
}}
|
||||
>
|
||||
{isStartingReplay
|
||||
? t("dialog.starting", { ns: "views/replay" })
|
||||
: t("title", { ns: "views/replay" })}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{isAdmin && onAssignToCase && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
|
||||
@@ -19,7 +19,7 @@ const birdseye: SectionConfigOverrides = {
|
||||
],
|
||||
restartRequired: [],
|
||||
fieldOrder: ["enabled", "mode", "order"],
|
||||
hiddenFields: [],
|
||||
hiddenFields: ["order"],
|
||||
advancedFields: [],
|
||||
overrideFields: ["enabled", "mode"],
|
||||
uiSchema: {
|
||||
@@ -56,6 +56,7 @@ const birdseye: SectionConfigOverrides = {
|
||||
uiSchema: {
|
||||
mode: {
|
||||
"ui:size": "xs",
|
||||
"ui:after": { render: "BirdseyeCameraReorder" },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -46,7 +46,11 @@ const record: SectionConfigOverrides = {
|
||||
uiSchema: {
|
||||
export: {
|
||||
hwaccel_args: {
|
||||
"ui:options": { suppressMultiSchema: true, size: "lg" },
|
||||
"ui:widget": "FfmpegArgsWidget",
|
||||
"ui:options": {
|
||||
suppressMultiSchema: true,
|
||||
ffmpegPresetField: "hwaccel_args",
|
||||
},
|
||||
},
|
||||
},
|
||||
"alerts.retain.mode": {
|
||||
|
||||
@@ -5,7 +5,7 @@ const ui: SectionConfigOverrides = {
|
||||
sectionDocs: "/configuration/reference",
|
||||
restartRequired: [],
|
||||
fieldOrder: ["dashboard", "order"],
|
||||
hiddenFields: [],
|
||||
hiddenFields: ["order"],
|
||||
advancedFields: [],
|
||||
overrideFields: [],
|
||||
},
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import useSWR from "swr";
|
||||
import { Reorder, useDragControls } from "framer-motion";
|
||||
import { LuCheck, LuGripVertical } from "react-icons/lu";
|
||||
import { SplitCardRow } from "@/components/card/SettingsGroupCard";
|
||||
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { SectionRendererProps } from "./registry";
|
||||
|
||||
const SAVED_INDICATOR_MS = 1500;
|
||||
|
||||
type SaveStatus = "idle" | "saving" | "saved";
|
||||
|
||||
export default function BirdseyeCameraReorder({
|
||||
formContext,
|
||||
}: SectionRendererProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
|
||||
const birdseyeCameras = useMemo(() => {
|
||||
if (!config) return [];
|
||||
return Object.keys(config.cameras)
|
||||
.filter(
|
||||
(name) =>
|
||||
config.cameras[name].enabled_in_config &&
|
||||
config.cameras[name].birdseye?.enabled !== false,
|
||||
)
|
||||
.sort((a, b) => {
|
||||
const orderA = config.cameras[a].birdseye?.order ?? 0;
|
||||
const orderB = config.cameras[b].birdseye?.order ?? 0;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}, [config]);
|
||||
|
||||
const [orderedCameras, setOrderedCameras] =
|
||||
useState<string[]>(birdseyeCameras);
|
||||
const orderedCamerasRef = useRef(orderedCameras);
|
||||
useEffect(() => {
|
||||
orderedCamerasRef.current = orderedCameras;
|
||||
}, [orderedCameras]);
|
||||
|
||||
useEffect(() => {
|
||||
setOrderedCameras((prev) => {
|
||||
if (
|
||||
prev.length === birdseyeCameras.length &&
|
||||
prev.every((cam, i) => cam === birdseyeCameras[i])
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return birdseyeCameras;
|
||||
});
|
||||
}, [birdseyeCameras]);
|
||||
|
||||
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
|
||||
const savedResetTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (savedResetTimerRef.current) {
|
||||
clearTimeout(savedResetTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleDragEnd = useCallback(async () => {
|
||||
const current = orderedCamerasRef.current;
|
||||
if (
|
||||
current.length === birdseyeCameras.length &&
|
||||
current.every((cam, i) => cam === birdseyeCameras[i])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraUpdates: Record<string, { birdseye: { order: number } }> = {};
|
||||
current.forEach((cam, i) => {
|
||||
cameraUpdates[cam] = { birdseye: { order: i * 10 } };
|
||||
});
|
||||
|
||||
if (savedResetTimerRef.current) {
|
||||
clearTimeout(savedResetTimerRef.current);
|
||||
savedResetTimerRef.current = null;
|
||||
}
|
||||
setSaveStatus("saving");
|
||||
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: { cameras: cameraUpdates },
|
||||
});
|
||||
await updateConfig();
|
||||
setSaveStatus("saved");
|
||||
savedResetTimerRef.current = setTimeout(() => {
|
||||
setSaveStatus("idle");
|
||||
savedResetTimerRef.current = null;
|
||||
}, SAVED_INDICATOR_MS);
|
||||
} catch (error) {
|
||||
setOrderedCameras(birdseyeCameras);
|
||||
setSaveStatus("idle");
|
||||
const errorMessage =
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.data?.message || error.response?.data?.detail)
|
||||
? error.response?.data?.message || error.response?.data?.detail
|
||||
: t("toast.save.error.noMessage", { ns: "common" });
|
||||
|
||||
toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}, [birdseyeCameras, updateConfig, t]);
|
||||
|
||||
if (formContext?.level && formContext.level !== "global") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!config || birdseyeCameras.length < 2) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<SplitCardRow
|
||||
label={t("birdseye.cameraOrder.label", { ns: "views/settings" })}
|
||||
description={t("birdseye.cameraOrder.description", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
content={
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
axis="y"
|
||||
values={orderedCameras}
|
||||
onReorder={setOrderedCameras}
|
||||
className="space-y-2 rounded-lg bg-secondary p-4"
|
||||
>
|
||||
{orderedCameras.map((camera) => (
|
||||
<BirdseyeCameraRow
|
||||
key={camera}
|
||||
camera={camera}
|
||||
onDragEnd={handleDragEnd}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
<SaveStatusIndicator status={saveStatus} />
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
type SaveStatusIndicatorProps = {
|
||||
status: SaveStatus;
|
||||
};
|
||||
|
||||
function SaveStatusIndicator({ status }: SaveStatusIndicatorProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"flex h-4 items-center justify-start gap-1 text-xs transition-opacity duration-200",
|
||||
status === "idle" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
{status === "saving" && (
|
||||
<span className="text-muted-foreground">
|
||||
{t("birdseye.cameraOrder.saving")}
|
||||
</span>
|
||||
)}
|
||||
{status === "saved" && (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<LuCheck className="size-3.5" />
|
||||
{t("birdseye.cameraOrder.saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type BirdseyeCameraRowProps = {
|
||||
camera: string;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
|
||||
function BirdseyeCameraRow({ camera, onDragEnd }: BirdseyeCameraRowProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const controls = useDragControls();
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
as="div"
|
||||
value={camera}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
onDragEnd={onDragEnd}
|
||||
className="flex flex-row items-center gap-1"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
className="-ml-1 cursor-grab touch-none rounded p-1 text-muted-foreground hover:text-primary active:cursor-grabbing"
|
||||
aria-label={t("birdseye.cameraOrder.reorderHandle")}
|
||||
>
|
||||
<LuGripVertical className="size-4" />
|
||||
</button>
|
||||
<CameraNameLabel camera={camera} />
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
|
||||
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
|
||||
import ProxyRoleMap from "./ProxyRoleMap";
|
||||
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
|
||||
import BirdseyeCameraReorder from "./BirdseyeCameraReorder";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
// Props that will be injected into all section renderers
|
||||
@@ -52,6 +53,9 @@ export const sectionRenderers: SectionRenderers = {
|
||||
notifications: {
|
||||
NotificationsSettingsExtras,
|
||||
},
|
||||
birdseye: {
|
||||
BirdseyeCameraReorder,
|
||||
},
|
||||
};
|
||||
|
||||
export default sectionRenderers;
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { Event } from "@/types/event";
|
||||
import { baseUrl } from "@/api/baseUrl";
|
||||
import { ReviewSegment, REVIEW_PADDING } from "@/types/review";
|
||||
@@ -12,6 +14,7 @@ import {
|
||||
DropdownMenuTrigger,
|
||||
DropdownMenuPortal,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { HiDotsHorizontal } from "react-icons/hi";
|
||||
import { SearchResult } from "@/types/search";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
@@ -33,9 +36,14 @@ export default function DetailActionsMenu({
|
||||
setSearch,
|
||||
setSimilarity,
|
||||
}: Props) {
|
||||
const { t } = useTranslation(["views/explore", "views/faceLibrary"]);
|
||||
const { t } = useTranslation([
|
||||
"views/explore",
|
||||
"views/faceLibrary",
|
||||
"views/replay",
|
||||
]);
|
||||
const navigate = useNavigate();
|
||||
const [isOpen, setIsOpen] = useState(false);
|
||||
const [isStarting, setIsStarting] = useState(false);
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const clipTimeRange = useMemo(() => {
|
||||
@@ -49,6 +57,54 @@ export default function DetailActionsMenu({
|
||||
search.data?.type === "audio" ? null : [`review/event/${search.id}`],
|
||||
);
|
||||
|
||||
const handleDebugReplay = useCallback(() => {
|
||||
setIsStarting(true);
|
||||
|
||||
axios
|
||||
.post("debug_replay/start", {
|
||||
camera: search.camera,
|
||||
start_time: search.start_time,
|
||||
end_time: search.end_time,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
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={`${baseUrl}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, search.camera, search.start_time, search.end_time, t]);
|
||||
|
||||
// don't render menu at all if no options are available
|
||||
const hasSemanticSearchOption =
|
||||
config?.semantic_search.enabled &&
|
||||
@@ -172,6 +228,24 @@ export default function DetailActionsMenu({
|
||||
</div>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
|
||||
{search.has_clip && (
|
||||
<DropdownMenuItem
|
||||
className="cursor-pointer"
|
||||
aria-label={t("itemMenu.debugReplay.aria")}
|
||||
disabled={isStarting}
|
||||
onSelect={() => {
|
||||
setIsOpen(false);
|
||||
handleDebugReplay();
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
{isStarting
|
||||
? t("dialog.starting", { ns: "views/replay" })
|
||||
: t("itemMenu.debugReplay.label")}
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenuPortal>
|
||||
</DropdownMenu>
|
||||
|
||||
@@ -59,6 +59,64 @@ function stripHiddenPaths(value: JsonValue, hiddenFields: string[]): JsonValue {
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Field paths that the backend resolves per-camera at runtime (from `fps`,
|
||||
* stream introspection, or other camera-local state) but defaults to `None`
|
||||
* in the global Pydantic model. Because the `/config` endpoint serializes
|
||||
* with `exclude_none=True`, these paths are absent from the global section
|
||||
* yet always populated on cameras, which would otherwise make every camera
|
||||
* appear to override fields the user never set globally.
|
||||
*/
|
||||
const AUTO_DERIVED_FIELDS: Record<string, readonly string[]> = {
|
||||
detect: [
|
||||
"width",
|
||||
"height",
|
||||
"min_initialized",
|
||||
"max_disappeared",
|
||||
"stationary.interval",
|
||||
"stationary.threshold",
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
* Drop auto-derived field paths from the camera value when the global value
|
||||
* has no explicit setting for that path. If the user later sets one of these
|
||||
* fields globally, the path will be present in `globalValue` and normal
|
||||
* comparison resumes.
|
||||
*/
|
||||
function stripAutoDerivedMissingFromGlobal(
|
||||
sectionPath: string,
|
||||
globalValue: JsonValue,
|
||||
cameraValue: JsonValue,
|
||||
): JsonValue {
|
||||
const fields = AUTO_DERIVED_FIELDS[sectionPath];
|
||||
if (!fields || !isJsonObject(cameraValue)) return cameraValue;
|
||||
const cloned = cloneDeep(cameraValue) as JsonObject;
|
||||
for (const path of fields) {
|
||||
if (get(globalValue, path) === undefined) {
|
||||
unsetWithWildcard(cloned as Record<string, unknown>, path);
|
||||
}
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the given field is auto-derived for `sectionPath` and the global
|
||||
* value at that path is missing — in which case a per-camera value should
|
||||
* not be treated as an override.
|
||||
*/
|
||||
function isAutoDerivedMissingFromGlobal(
|
||||
sectionPath: string,
|
||||
fieldPath: string,
|
||||
globalValue: unknown,
|
||||
): boolean {
|
||||
const fields = AUTO_DERIVED_FIELDS[sectionPath];
|
||||
if (!fields) return false;
|
||||
if (!fields.includes(fieldPath)) return false;
|
||||
const value = get(globalValue as JsonObject, fieldPath);
|
||||
return value === undefined || value === null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse null and empty-object values for override comparisons so
|
||||
* semantically equivalent shapes match. The schema may default `mask: None`
|
||||
@@ -234,10 +292,15 @@ export function useConfigOverride({
|
||||
collapseEmpty(normalizedGlobalValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const collapsedCamera = stripHiddenPaths(
|
||||
const collapsedCameraRaw = stripHiddenPaths(
|
||||
collapseEmpty(normalizedCameraValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const collapsedCamera = stripAutoDerivedMissingFromGlobal(
|
||||
sectionPath,
|
||||
collapsedGlobal,
|
||||
collapsedCameraRaw,
|
||||
);
|
||||
|
||||
const comparisonGlobal = compareFields
|
||||
? pickFields(collapsedGlobal, compareFields)
|
||||
@@ -258,6 +321,20 @@ export function useConfigOverride({
|
||||
const globalFieldValue = get(normalizedGlobalValue, fieldPath);
|
||||
const cameraFieldValue = get(normalizedCameraValue, fieldPath);
|
||||
|
||||
if (
|
||||
isAutoDerivedMissingFromGlobal(
|
||||
sectionPath,
|
||||
fieldPath,
|
||||
normalizedGlobalValue,
|
||||
)
|
||||
) {
|
||||
return {
|
||||
isOverridden: false,
|
||||
globalValue: globalFieldValue,
|
||||
cameraValue: cameraFieldValue,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
isOverridden: !isEqual(
|
||||
collapseEmpty(globalFieldValue as JsonValue),
|
||||
@@ -367,10 +444,15 @@ export function useAllCameraOverrides(
|
||||
collapseEmpty(globalValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const collapsedCamera = stripHiddenPaths(
|
||||
const collapsedCameraRaw = stripHiddenPaths(
|
||||
collapseEmpty(cameraValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const collapsedCamera = stripAutoDerivedMissingFromGlobal(
|
||||
key,
|
||||
collapsedGlobal,
|
||||
collapsedCameraRaw,
|
||||
);
|
||||
const comparisonGlobal = compareFields
|
||||
? pickFields(collapsedGlobal, compareFields)
|
||||
: collapsedGlobal;
|
||||
@@ -615,7 +697,11 @@ export function useCamerasOverridingSection(
|
||||
const deltasByPath = new Map<string, FieldDelta>();
|
||||
|
||||
// 1. Camera-level overrides (uses base_config when a profile is active)
|
||||
const cameraValue = collapseEmpty(cameraSectionValues[idx]);
|
||||
const cameraValue = stripAutoDerivedMissingFromGlobal(
|
||||
sectionPath,
|
||||
globalValue,
|
||||
collapseEmpty(cameraSectionValues[idx]),
|
||||
);
|
||||
for (const delta of collectFieldDeltas(
|
||||
globalValue,
|
||||
cameraValue,
|
||||
@@ -696,9 +782,13 @@ export function useCameraSectionDeltas(
|
||||
const globalValue = collapseEmpty(
|
||||
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
|
||||
);
|
||||
const cameraValue = collapseEmpty(
|
||||
normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, cameraName, sectionPath),
|
||||
const cameraValue = stripAutoDerivedMissingFromGlobal(
|
||||
sectionPath,
|
||||
globalValue,
|
||||
collapseEmpty(
|
||||
normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, cameraName, sectionPath),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
|
||||
+18
-14
@@ -10,7 +10,7 @@ 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 { useFrigateStats, useJobStatus } from "@/api/ws";
|
||||
import { useIsAdmin } from "./use-is-admin";
|
||||
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -19,11 +19,15 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
const { t } = useTranslation(["views/system"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const isAdmin = useIsAdmin();
|
||||
const { data: debugReplayStatus } = useSWR(
|
||||
isAdmin ? "debug_replay/status" : null,
|
||||
{
|
||||
revalidateOnFocus: false,
|
||||
},
|
||||
|
||||
// Pass isAdmin as revalidateOnFocus so non-admins never send the jobState snapshot pull
|
||||
const { payload: replayJob } = useJobStatus("debug_replay", isAdmin);
|
||||
const replayActive = Boolean(
|
||||
isAdmin &&
|
||||
replayJob &&
|
||||
(replayJob.status === "queued" ||
|
||||
replayJob.status === "running" ||
|
||||
replayJob.status === "success"),
|
||||
);
|
||||
|
||||
const memoizedStats = useDeepMemo(stats);
|
||||
@@ -102,6 +106,11 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
|
||||
// check camera cpu usages
|
||||
Object.entries(memoizedStats["cameras"]).forEach(([name, cam]) => {
|
||||
// Skip replay cameras
|
||||
if (isReplayCamera(name)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const ffmpegAvg = parseFloat(
|
||||
memoizedStats["cpu_usages"][cam["ffmpeg_pid"]]?.cpu_average,
|
||||
);
|
||||
@@ -111,12 +120,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
|
||||
const cameraName = config?.cameras?.[name]?.friendly_name ?? name;
|
||||
|
||||
// Skip ffmpeg warnings for replay cameras
|
||||
if (
|
||||
!isNaN(ffmpegAvg) &&
|
||||
ffmpegAvg >= CameraFfmpegThreshold.error &&
|
||||
!isReplayCamera(name)
|
||||
) {
|
||||
if (!isNaN(ffmpegAvg) && ffmpegAvg >= CameraFfmpegThreshold.error) {
|
||||
problems.push({
|
||||
text: t("stats.ffmpegHighCpuUsage", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
@@ -140,7 +144,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
});
|
||||
|
||||
// Add message if debug replay is active
|
||||
if (debugReplayStatus?.active) {
|
||||
if (replayActive) {
|
||||
problems.push({
|
||||
text: t("stats.debugReplayActive", {
|
||||
defaultValue: "Debug replay session is active",
|
||||
@@ -151,7 +155,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
}
|
||||
|
||||
return problems;
|
||||
}, [config, memoizedStats, t, debugReplayStatus]);
|
||||
}, [config, memoizedStats, t, replayActive]);
|
||||
|
||||
return { potentialProblems };
|
||||
}
|
||||
|
||||
@@ -106,6 +106,12 @@ import SaveAllPreviewPopover, {
|
||||
type SaveAllPreviewItem,
|
||||
} from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||
import { useRestart } from "@/api/ws";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { TooltipPortal } from "@radix-ui/react-tooltip";
|
||||
|
||||
const allSettingsViews = [
|
||||
"uiSettings",
|
||||
@@ -1505,10 +1511,20 @@ export default function Settings() {
|
||||
CAMERA_SECTION_KEYS.has(key) && status?.isOverridden;
|
||||
const showUnsavedDot = status?.hasChanges;
|
||||
|
||||
const dotColor =
|
||||
status?.overrideSource === "profile" && activeProfileColor
|
||||
? activeProfileColor.dot
|
||||
: "bg-selected";
|
||||
const isProfileOverride =
|
||||
status?.overrideSource === "profile" && activeProfileColor;
|
||||
const dotColor = isProfileOverride
|
||||
? activeProfileColor.dot
|
||||
: "bg-selected";
|
||||
|
||||
const overrideTooltip = isProfileOverride
|
||||
? t("menuDot.overrideProfile", {
|
||||
profile: activeEditingProfile
|
||||
? (profileFriendlyNames.get(activeEditingProfile) ??
|
||||
activeEditingProfile)
|
||||
: "",
|
||||
})
|
||||
: t("menuDot.overrideGlobal");
|
||||
|
||||
return (
|
||||
<div className="flex w-full min-w-0 items-center justify-between pr-4 md:pr-0">
|
||||
@@ -1518,19 +1534,46 @@ export default function Settings() {
|
||||
{(showOverrideDot || showUnsavedDot) && (
|
||||
<div className="ml-2 flex shrink-0 items-center gap-2">
|
||||
{showOverrideDot && (
|
||||
<span
|
||||
className={cn("inline-block size-2 rounded-full", dotColor)}
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span
|
||||
className={cn(
|
||||
"inline-block size-2 rounded-full",
|
||||
dotColor,
|
||||
)}
|
||||
/>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="right">
|
||||
{overrideTooltip}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
)}
|
||||
{showUnsavedDot && (
|
||||
<span className="inline-block size-2 rounded-full bg-unsaved" />
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="inline-block size-2 rounded-full bg-unsaved" />
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent side="right">
|
||||
{t("menuDot.unsaved")}
|
||||
</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
[sectionStatusByKey, t, activeProfileColor],
|
||||
[
|
||||
sectionStatusByKey,
|
||||
t,
|
||||
activeProfileColor,
|
||||
activeEditingProfile,
|
||||
profileFriendlyNames,
|
||||
],
|
||||
);
|
||||
|
||||
if (isMobile) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import Heading from "@/components/ui/heading";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
CONTROL_COLUMN_CLASS_NAME,
|
||||
SettingsGroupCard,
|
||||
@@ -14,7 +14,15 @@ import { useTranslation } from "react-i18next";
|
||||
import CameraEditForm from "@/components/settings/CameraEditForm";
|
||||
import CameraWizardDialog from "@/components/settings/CameraWizardDialog";
|
||||
import DeleteCameraDialog from "@/components/overlay/dialog/DeleteCameraDialog";
|
||||
import { LuExternalLink, LuPencil, LuPlus, LuTrash2 } from "react-icons/lu";
|
||||
import {
|
||||
LuCheck,
|
||||
LuExternalLink,
|
||||
LuGripVertical,
|
||||
LuPencil,
|
||||
LuPlus,
|
||||
LuTrash2,
|
||||
} from "react-icons/lu";
|
||||
import { Reorder, useDragControls } from "framer-motion";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
@@ -45,6 +53,10 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
|
||||
const REORDER_SAVED_INDICATOR_MS = 1500;
|
||||
|
||||
type ReorderSaveStatus = "idle" | "saving" | "saved";
|
||||
|
||||
type CameraManagementViewProps = {
|
||||
setUnsavedChanges: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
profileState?: ProfileState;
|
||||
@@ -54,7 +66,7 @@ export default function CameraManagementView({
|
||||
setUnsavedChanges,
|
||||
profileState,
|
||||
}: CameraManagementViewProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
|
||||
const { data: config, mutate: updateConfig } =
|
||||
useSWR<FrigateConfig>("config");
|
||||
@@ -72,16 +84,99 @@ export default function CameraManagementView({
|
||||
const [restartDialogOpen, setRestartDialogOpen] = useState(false);
|
||||
const { send: sendRestart } = useRestart();
|
||||
|
||||
// List of cameras for dropdown
|
||||
const enabledCameras = useMemo(() => {
|
||||
if (config) {
|
||||
return Object.keys(config.cameras)
|
||||
.filter((camera) => config.cameras[camera].enabled_in_config)
|
||||
.sort();
|
||||
.sort((a, b) => {
|
||||
const orderA = config.cameras[a].ui?.order ?? 0;
|
||||
const orderB = config.cameras[b].ui?.order ?? 0;
|
||||
if (orderA !== orderB) return orderA - orderB;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
}
|
||||
return [];
|
||||
}, [config]);
|
||||
|
||||
// Diverges from config during a drag and while the save is in flight.
|
||||
const [orderedCameras, setOrderedCameras] =
|
||||
useState<string[]>(enabledCameras);
|
||||
const orderedCamerasRef = useRef(orderedCameras);
|
||||
useEffect(() => {
|
||||
orderedCamerasRef.current = orderedCameras;
|
||||
}, [orderedCameras]);
|
||||
|
||||
useEffect(() => {
|
||||
setOrderedCameras((prev) => {
|
||||
if (
|
||||
prev.length === enabledCameras.length &&
|
||||
prev.every((cam, i) => cam === enabledCameras[i])
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return enabledCameras;
|
||||
});
|
||||
}, [enabledCameras]);
|
||||
|
||||
const [reorderSaveStatus, setReorderSaveStatus] =
|
||||
useState<ReorderSaveStatus>("idle");
|
||||
const reorderSavedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
|
||||
null,
|
||||
);
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (reorderSavedTimerRef.current) {
|
||||
clearTimeout(reorderSavedTimerRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
const handleReorderDragEnd = useCallback(async () => {
|
||||
const current = orderedCamerasRef.current;
|
||||
if (
|
||||
current.length === enabledCameras.length &&
|
||||
current.every((cam, i) => cam === enabledCameras[i])
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const cameraUpdates: Record<string, { ui: { order: number } }> = {};
|
||||
current.forEach((cam, i) => {
|
||||
cameraUpdates[cam] = { ui: { order: i * 10 } };
|
||||
});
|
||||
|
||||
if (reorderSavedTimerRef.current) {
|
||||
clearTimeout(reorderSavedTimerRef.current);
|
||||
reorderSavedTimerRef.current = null;
|
||||
}
|
||||
setReorderSaveStatus("saving");
|
||||
|
||||
try {
|
||||
await axios.put("config/set", {
|
||||
requires_restart: 0,
|
||||
config_data: { cameras: cameraUpdates },
|
||||
});
|
||||
await updateConfig();
|
||||
setReorderSaveStatus("saved");
|
||||
reorderSavedTimerRef.current = setTimeout(() => {
|
||||
setReorderSaveStatus("idle");
|
||||
reorderSavedTimerRef.current = null;
|
||||
}, REORDER_SAVED_INDICATOR_MS);
|
||||
} catch (error) {
|
||||
setOrderedCameras(enabledCameras);
|
||||
setReorderSaveStatus("idle");
|
||||
const errorMessage =
|
||||
axios.isAxiosError(error) &&
|
||||
(error.response?.data?.message || error.response?.data?.detail)
|
||||
? error.response?.data?.message || error.response?.data?.detail
|
||||
: t("toast.save.error.noMessage", { ns: "common" });
|
||||
|
||||
toast.error(t("toast.save.error.title", { errorMessage, ns: "common" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
}
|
||||
}, [enabledCameras, updateConfig, t]);
|
||||
|
||||
const disabledCameras = useMemo(() => {
|
||||
if (config) {
|
||||
return Object.keys(config.cameras)
|
||||
@@ -173,22 +268,26 @@ export default function CameraManagementView({
|
||||
</p>
|
||||
</Label>
|
||||
</div>
|
||||
<div className="max-w-md space-y-2 rounded-lg bg-secondary p-4">
|
||||
{enabledCameras.map((camera) => (
|
||||
<div
|
||||
key={camera}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<CameraNameLabel camera={camera} />
|
||||
<CameraFriendlyNameEditor
|
||||
cameraName={camera}
|
||||
onConfigChanged={updateConfig}
|
||||
/>
|
||||
</div>
|
||||
<CameraEnableSwitch cameraName={camera} />
|
||||
</div>
|
||||
))}
|
||||
<div className="max-w-md space-y-1.5">
|
||||
<Reorder.Group
|
||||
as="div"
|
||||
axis="y"
|
||||
values={orderedCameras}
|
||||
onReorder={setOrderedCameras}
|
||||
className="space-y-2 rounded-lg bg-secondary p-4"
|
||||
>
|
||||
{orderedCameras.map((camera) => (
|
||||
<EnabledCameraRow
|
||||
key={camera}
|
||||
camera={camera}
|
||||
onConfigChanged={updateConfig}
|
||||
onDragEnd={handleReorderDragEnd}
|
||||
/>
|
||||
))}
|
||||
</Reorder.Group>
|
||||
<ReorderSaveStatusIndicator
|
||||
status={reorderSaveStatus}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground md:hidden">
|
||||
<Trans ns="views/settings">
|
||||
@@ -309,6 +408,80 @@ export default function CameraManagementView({
|
||||
);
|
||||
}
|
||||
|
||||
type ReorderSaveStatusIndicatorProps = {
|
||||
status: ReorderSaveStatus;
|
||||
};
|
||||
|
||||
function ReorderSaveStatusIndicator({
|
||||
status,
|
||||
}: ReorderSaveStatusIndicatorProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className={cn(
|
||||
"flex h-4 items-center justify-start gap-1 text-xs transition-opacity duration-200",
|
||||
status === "idle" ? "opacity-0" : "opacity-100",
|
||||
)}
|
||||
>
|
||||
{status === "saving" && (
|
||||
<span className="text-muted-foreground">
|
||||
{t("cameraManagement.streams.saving")}
|
||||
</span>
|
||||
)}
|
||||
{status === "saved" && (
|
||||
<span className="flex items-center gap-1 text-success">
|
||||
<LuCheck className="size-3.5" />
|
||||
{t("cameraManagement.streams.saved")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type EnabledCameraRowProps = {
|
||||
camera: string;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
onDragEnd: () => void;
|
||||
};
|
||||
|
||||
function EnabledCameraRow({
|
||||
camera,
|
||||
onConfigChanged,
|
||||
onDragEnd,
|
||||
}: EnabledCameraRowProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const controls = useDragControls();
|
||||
|
||||
return (
|
||||
<Reorder.Item
|
||||
as="div"
|
||||
value={camera}
|
||||
dragListener={false}
|
||||
dragControls={controls}
|
||||
onDragEnd={onDragEnd}
|
||||
className="flex flex-row items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onPointerDown={(e) => controls.start(e)}
|
||||
className="-ml-1 cursor-grab touch-none rounded p-1 text-muted-foreground hover:text-primary active:cursor-grabbing"
|
||||
aria-label={t("cameraManagement.streams.reorderHandle")}
|
||||
>
|
||||
<LuGripVertical className="size-4" />
|
||||
</button>
|
||||
<CameraNameLabel camera={camera} />
|
||||
<CameraFriendlyNameEditor
|
||||
cameraName={camera}
|
||||
onConfigChanged={onConfigChanged}
|
||||
/>
|
||||
</div>
|
||||
<CameraEnableSwitch cameraName={camera} />
|
||||
</Reorder.Item>
|
||||
);
|
||||
}
|
||||
|
||||
type CameraEnableSwitchProps = {
|
||||
cameraName: string;
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user