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

* 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:
Josh Hawkins
2026-05-18 22:52:40 -05:00
committed by GitHub
co-authored by Nicolas Mowen
parent d968f00500
commit 43d97acd21
28 changed files with 1176 additions and 170 deletions
+11
View File
@@ -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]);
+71 -1
View File
@@ -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>
+96 -6
View File
@@ -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
View File
@@ -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 };
}
+52 -9
View File
@@ -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) {
+194 -21
View File
@@ -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;
};