mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-29 07:09:03 +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
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user