mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-11-12 00:23:01 +03:00
Merge branch 'blakeblackshear:dev' into dev
This commit is contained in:
@@ -56,7 +56,14 @@ export function WsProvider({ children }: { children: ReactNode }) {
|
||||
if (reconnectTimer.current) {
|
||||
clearTimeout(reconnectTimer.current);
|
||||
}
|
||||
wsRef.current?.close();
|
||||
const ws = wsRef.current;
|
||||
if (ws) {
|
||||
ws.onopen = null;
|
||||
ws.onmessage = null;
|
||||
ws.onclose = null;
|
||||
ws.onerror = null;
|
||||
ws.close();
|
||||
}
|
||||
resetWsStore();
|
||||
};
|
||||
}, [wsUrl]);
|
||||
|
||||
@@ -17,6 +17,9 @@ import { useUserPersistence } from "@/hooks/use-user-persistence";
|
||||
import { Skeleton } from "../ui/skeleton";
|
||||
import { Button } from "../ui/button";
|
||||
import { FaCircleCheck } from "react-icons/fa6";
|
||||
import { FaExclamationTriangle } from "react-icons/fa";
|
||||
import { MdOutlinePersonSearch } from "react-icons/md";
|
||||
import { ThreatLevel } from "@/types/review";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
@@ -127,6 +130,11 @@ export function AnimatedEventCard({
|
||||
true,
|
||||
);
|
||||
|
||||
const threatLevel = useMemo<ThreatLevel>(
|
||||
() => (event.data.metadata?.potential_threat_level ?? 0) as ThreatLevel,
|
||||
[event],
|
||||
);
|
||||
|
||||
const aspectRatio = useMemo(() => {
|
||||
if (
|
||||
!config ||
|
||||
@@ -152,7 +160,15 @@ export function AnimatedEventCard({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
className="pointer-events-none absolute left-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 opacity-0 transition-opacity group-hover:pointer-events-auto group-hover:opacity-100"
|
||||
className={cn(
|
||||
"absolute left-2 top-1 z-40 bg-gray-500 bg-gradient-to-br from-gray-400 to-gray-500 transition-opacity",
|
||||
threatLevel === ThreatLevel.SECURITY_CONCERN &&
|
||||
"pointer-events-auto opacity-100",
|
||||
threatLevel === ThreatLevel.NEEDS_REVIEW &&
|
||||
"pointer-events-auto opacity-100",
|
||||
threatLevel === ThreatLevel.NORMAL &&
|
||||
"pointer-events-none opacity-0 group-hover:pointer-events-auto group-hover:opacity-100",
|
||||
)}
|
||||
size="xs"
|
||||
aria-label={t("markAsReviewed")}
|
||||
onClick={async () => {
|
||||
@@ -160,7 +176,13 @@ export function AnimatedEventCard({
|
||||
updateEvents();
|
||||
}}
|
||||
>
|
||||
<FaCircleCheck className="size-3 text-white" />
|
||||
{threatLevel === ThreatLevel.SECURITY_CONCERN ? (
|
||||
<FaExclamationTriangle className="size-3 text-white" />
|
||||
) : threatLevel === ThreatLevel.NEEDS_REVIEW ? (
|
||||
<MdOutlinePersonSearch className="size-3 text-white" />
|
||||
) : (
|
||||
<FaCircleCheck className="size-3 text-white" />
|
||||
)}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{t("markAsReviewed")}</TooltipContent>
|
||||
|
||||
@@ -12,6 +12,7 @@ type EmptyCardProps = {
|
||||
description?: string;
|
||||
buttonText?: string;
|
||||
link?: string;
|
||||
onClick?: () => void;
|
||||
};
|
||||
export function EmptyCard({
|
||||
className,
|
||||
@@ -21,6 +22,7 @@ export function EmptyCard({
|
||||
description,
|
||||
buttonText,
|
||||
link,
|
||||
onClick,
|
||||
}: EmptyCardProps) {
|
||||
let TitleComponent;
|
||||
|
||||
@@ -39,11 +41,16 @@ export function EmptyCard({
|
||||
{description}
|
||||
</div>
|
||||
)}
|
||||
{buttonText?.length && (
|
||||
<Button size="sm" variant="select">
|
||||
<Link to={link ?? "#"}>{buttonText}</Link>
|
||||
</Button>
|
||||
)}
|
||||
{buttonText?.length &&
|
||||
(onClick ? (
|
||||
<Button size="sm" variant="select" onClick={onClick}>
|
||||
{buttonText}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="sm" variant="select">
|
||||
<Link to={link ?? "#"}>{buttonText}</Link>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -266,7 +266,7 @@ export function ExportCard({
|
||||
)}
|
||||
{!exportedRecording.in_progress && !selectionMode && (
|
||||
<div className="absolute bottom-2 right-3 z-40">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<BlurredIconButton
|
||||
aria-label={t("tooltip.editName")}
|
||||
|
||||
@@ -79,13 +79,18 @@ export default function ReviewCard({
|
||||
? event.end_time + REVIEW_PADDING
|
||||
: Date.now() / 1000;
|
||||
|
||||
const genAiTitle = event.data.metadata?.title?.trim();
|
||||
|
||||
axios
|
||||
.post(
|
||||
`export/${event.camera}/start/${event.start_time - REVIEW_PADDING}/end/${endTime}`,
|
||||
{ playback: "realtime" },
|
||||
{
|
||||
playback: "realtime",
|
||||
...(genAiTitle ? { name: genAiTitle } : {}),
|
||||
},
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
if (response.status < 300) {
|
||||
toast.success(t("export.toast.success"), {
|
||||
position: "top-center",
|
||||
action: (
|
||||
@@ -275,7 +280,7 @@ export default function ReviewCard({
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ContextMenu key={event.id} modal={false}>
|
||||
<ContextMenu key={event.id}>
|
||||
<ContextMenuTrigger asChild>{content}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<ContextMenuItem>
|
||||
|
||||
@@ -6,7 +6,6 @@ import {
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
type ChatEvent = { id: string; score?: number };
|
||||
|
||||
@@ -37,10 +36,7 @@ export function ChatEventThumbnailsRow({
|
||||
const renderThumb = (event: ChatEvent, isAnchor = false) => (
|
||||
<div
|
||||
key={event.id}
|
||||
className={cn(
|
||||
"relative aspect-square size-32 shrink-0 overflow-hidden rounded-lg",
|
||||
isAnchor && "ring-2 ring-primary",
|
||||
)}
|
||||
className="relative aspect-square size-32 shrink-0 overflow-hidden rounded-lg"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
@@ -71,9 +67,15 @@ export function ChatEventThumbnailsRow({
|
||||
<TooltipContent>{t("open_in_explore")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{isAnchor && (
|
||||
<span className="pointer-events-none absolute left-1 top-1 rounded bg-primary px-1 text-[10px] text-primary-foreground">
|
||||
{t("anchor")}
|
||||
</span>
|
||||
<>
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-0 rounded-lg ring-2 ring-inset ring-primary"
|
||||
/>
|
||||
<span className="pointer-events-none absolute left-1 top-1 rounded bg-primary px-1 text-[10px] text-primary-foreground">
|
||||
{t("anchor")}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
|
||||
import { parseAttachedEvent } from "@/utils/chatUtil";
|
||||
import type { ChatStats, ShowStatsMode } from "@/types/chat";
|
||||
|
||||
type MessageBubbleProps = {
|
||||
role: "user" | "assistant";
|
||||
@@ -24,14 +25,29 @@ type MessageBubbleProps = {
|
||||
messageIndex?: number;
|
||||
onEditSubmit?: (messageIndex: number, newContent: string) => void;
|
||||
isComplete?: boolean;
|
||||
stats?: ChatStats;
|
||||
showStats?: ShowStatsMode;
|
||||
};
|
||||
|
||||
function formatTokens(n: number | undefined): string | null {
|
||||
if (n === undefined) return null;
|
||||
if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
|
||||
return String(n);
|
||||
}
|
||||
|
||||
function formatRate(rate: number | undefined): string | null {
|
||||
if (rate === undefined || rate <= 0) return null;
|
||||
return rate >= 10 ? rate.toFixed(0) : rate.toFixed(1);
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
role,
|
||||
content,
|
||||
messageIndex = 0,
|
||||
onEditSubmit,
|
||||
isComplete = true,
|
||||
stats,
|
||||
showStats = "while_generating",
|
||||
}: MessageBubbleProps) {
|
||||
const { t } = useTranslation(["views/chat", "common"]);
|
||||
const isUser = role === "user";
|
||||
@@ -155,14 +171,40 @@ export function MessageBubble({
|
||||
) : (
|
||||
<div
|
||||
className={cn(
|
||||
"[&>*:last-child]:inline",
|
||||
!isComplete &&
|
||||
"after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-['']",
|
||||
"after:ml-0.5 after:inline-block after:h-4 after:w-2 after:animate-cursor-blink after:rounded-sm after:bg-foreground after:align-middle after:content-[''] [&>p:last-child]:inline",
|
||||
)}
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={[remarkGfm]}
|
||||
components={{
|
||||
p: ({ node: _n, ...props }) => (
|
||||
<p className="my-2 first:mt-0 last:mb-0" {...props} />
|
||||
),
|
||||
ul: ({ node: _n, ...props }) => (
|
||||
<ul
|
||||
className="my-2 list-disc space-y-1 pl-6 first:mt-0 last:mb-0"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
ol: ({ node: _n, ...props }) => (
|
||||
<ol
|
||||
className="my-2 list-decimal space-y-1 pl-6 first:mt-0 last:mb-0"
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
li: ({ node: _n, ...props }) => (
|
||||
<li className="pl-1" {...props} />
|
||||
),
|
||||
code: ({ node: _n, className, ...props }) => (
|
||||
<code
|
||||
className={cn(
|
||||
"rounded bg-foreground/10 px-1 py-0.5 font-mono text-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
table: ({ node: _n, ...props }) => (
|
||||
<table
|
||||
className="my-2 w-full border-collapse border border-border"
|
||||
@@ -188,7 +230,7 @@ export function MessageBubble({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-0.5">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{isUser && onEditSubmit != null && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -230,6 +272,27 @@ export function MessageBubble({
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
{!isUser &&
|
||||
stats &&
|
||||
(showStats === "always" || !isComplete) &&
|
||||
(() => {
|
||||
const ctx = formatTokens(stats.promptTokens);
|
||||
const rate = formatRate(stats.tokensPerSecond);
|
||||
if (ctx === null && rate === null) return null;
|
||||
return (
|
||||
<div className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{ctx !== null && (
|
||||
<span>{t("stats.context", { tokens: ctx })}</span>
|
||||
)}
|
||||
{ctx !== null && rate !== null && (
|
||||
<span aria-hidden="true">·</span>
|
||||
)}
|
||||
{rate !== null && (
|
||||
<span>{t("stats.tokens_per_second", { rate })}</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { useState } from "react";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import { cn } from "@/lib/utils";
|
||||
import PlatformAwareDialog from "../overlay/dialog/PlatformAwareDialog";
|
||||
import { FaCog } from "react-icons/fa";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectGroup,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
} from "@/components/ui/select";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { DropdownMenuSeparator } from "@/components/ui/dropdown-menu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { ShowStatsMode } from "@/types/chat";
|
||||
|
||||
type ChatSettingsProps = {
|
||||
showStats: ShowStatsMode;
|
||||
setShowStats: (mode: ShowStatsMode) => void;
|
||||
autoScroll: boolean;
|
||||
setAutoScroll: (enabled: boolean) => void;
|
||||
};
|
||||
|
||||
export default function ChatSettings({
|
||||
showStats,
|
||||
setShowStats,
|
||||
autoScroll,
|
||||
setAutoScroll,
|
||||
}: ChatSettingsProps) {
|
||||
const { t } = useTranslation(["views/chat"]);
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const trigger = (
|
||||
<Button
|
||||
className="flex items-center md:gap-2"
|
||||
aria-label={t("settings.title")}
|
||||
size="sm"
|
||||
>
|
||||
<FaCog className="text-secondary-foreground" />
|
||||
<span className="hidden md:inline">{t("settings.title")}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
const content = (
|
||||
<div className="my-3 space-y-5 py-3 md:mt-0 md:py-0">
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-0.5">
|
||||
<div className="text-md">{t("settings.show_stats.title")}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("settings.show_stats.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Select
|
||||
value={showStats}
|
||||
onValueChange={(v) => setShowStats(v as ShowStatsMode)}
|
||||
>
|
||||
<SelectTrigger className="w-full">
|
||||
{showStats === "always"
|
||||
? t("settings.show_stats.always")
|
||||
: t("settings.show_stats.while_generating")}
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectGroup>
|
||||
<SelectItem className="cursor-pointer" value="while_generating">
|
||||
{t("settings.show_stats.while_generating")}
|
||||
</SelectItem>
|
||||
<SelectItem className="cursor-pointer" value="always">
|
||||
{t("settings.show_stats.always")}
|
||||
</SelectItem>
|
||||
</SelectGroup>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<DropdownMenuSeparator />
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="space-y-0.5">
|
||||
<Label htmlFor="auto-scroll" className="text-md cursor-pointer">
|
||||
{t("settings.auto_scroll.title")}
|
||||
</Label>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{t("settings.auto_scroll.desc")}
|
||||
</div>
|
||||
</div>
|
||||
<Switch
|
||||
id="auto-scroll"
|
||||
checked={autoScroll}
|
||||
onCheckedChange={setAutoScroll}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PlatformAwareDialog
|
||||
trigger={trigger}
|
||||
content={content}
|
||||
contentClassName={cn(
|
||||
"scrollbar-container h-auto overflow-y-auto",
|
||||
isDesktop ? "max-h-[80dvh] w-72" : "px-4",
|
||||
)}
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -54,7 +54,9 @@ export function ChatStartingState({ onSendMessage }: ChatStartingStateProps) {
|
||||
<div className="flex size-full flex-col items-center justify-center gap-6 p-8">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<h1 className="text-4xl font-bold text-foreground">{t("title")}</h1>
|
||||
<p className="text-muted-foreground">{t("subtitle")}</p>
|
||||
<p className="text-center text-muted-foreground md:text-left">
|
||||
{t("subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex w-full max-w-2xl flex-col items-center gap-4">
|
||||
|
||||
@@ -14,7 +14,6 @@ import Step3ChooseExamples, {
|
||||
Step3FormData,
|
||||
} from "./wizard/Step3ChooseExamples";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { isDesktop } from "react-device-detect";
|
||||
import axios from "axios";
|
||||
|
||||
const OBJECT_STEPS = [
|
||||
@@ -153,13 +152,9 @@ export default function ClassificationModelWizardDialog({
|
||||
>
|
||||
<DialogContent
|
||||
className={cn(
|
||||
"",
|
||||
isDesktop &&
|
||||
wizardState.currentStep == 0 &&
|
||||
"max-h-[90%] overflow-y-auto xl:max-h-[80%]",
|
||||
isDesktop &&
|
||||
wizardState.currentStep > 0 &&
|
||||
"max-h-[90%] max-w-[70%] overflow-y-auto xl:max-h-[80%]",
|
||||
"scrollbar-container max-h-[90%] overflow-y-auto",
|
||||
wizardState.currentStep == 0 && "xl:max-h-[80%]",
|
||||
wizardState.currentStep > 0 && "md:max-w-[70%] xl:max-h-[80%]",
|
||||
)}
|
||||
onInteractOutside={(e) => {
|
||||
e.preventDefault();
|
||||
|
||||
@@ -322,7 +322,7 @@ export default function Step1NameAndDefine({
|
||||
<FormLabel className="text-primary-variant">
|
||||
{t("wizard.step1.classificationType")}
|
||||
</FormLabel>
|
||||
<Popover modal={true}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -405,7 +405,7 @@ export default function Step1NameAndDefine({
|
||||
? t("wizard.step1.states")
|
||||
: t("wizard.step1.classes")}
|
||||
</FormLabel>
|
||||
<Popover modal={true}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
|
||||
@@ -238,11 +238,7 @@ export default function Step2StateArea({
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-medium">{t("wizard.step2.cameras")}</h3>
|
||||
{availableCameras.length > 0 ? (
|
||||
<Popover
|
||||
open={isPopoverOpen}
|
||||
onOpenChange={setIsPopoverOpen}
|
||||
modal={true}
|
||||
>
|
||||
<Popover open={isPopoverOpen} onOpenChange={setIsPopoverOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -42,6 +42,7 @@ const audio: SectionConfigOverrides = {
|
||||
"filters.*": {
|
||||
"ui:options": {
|
||||
additionalPropertyKeyReadonly: true,
|
||||
isAudioLabels: true,
|
||||
},
|
||||
},
|
||||
listen: {
|
||||
|
||||
@@ -10,7 +10,11 @@ const audioTranscription: SectionConfigOverrides = {
|
||||
severity: "warning",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
return ctx.fullCameraConfig.audio.enabled === false;
|
||||
return (
|
||||
!ctx.fullCameraConfig.ffmpeg?.inputs?.some((input) =>
|
||||
input.roles?.includes("audio"),
|
||||
) || ctx.fullCameraConfig.audio.enabled === false
|
||||
);
|
||||
}
|
||||
return false;
|
||||
},
|
||||
@@ -21,6 +25,11 @@ const audioTranscription: SectionConfigOverrides = {
|
||||
hiddenFields: ["enabled_in_config", "live_enabled"],
|
||||
advancedFields: ["language", "device", "model_size"],
|
||||
overrideFields: ["enabled", "live_enabled"],
|
||||
uiSchema: {
|
||||
model_size: {
|
||||
"ui:options": { size: "xs", enumI18nPrefix: "modelSize" },
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
fieldOrder: ["enabled", "language", "device", "model_size"],
|
||||
|
||||
@@ -22,6 +22,14 @@ const birdseye: SectionConfigOverrides = {
|
||||
hiddenFields: [],
|
||||
advancedFields: [],
|
||||
overrideFields: ["enabled", "mode"],
|
||||
uiSchema: {
|
||||
mode: {
|
||||
"ui:size": "xs",
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "birdseye.trackingMode",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
fieldOrder: [
|
||||
|
||||
@@ -3,6 +3,15 @@ import type { SectionConfigOverrides } from "./types";
|
||||
const detect: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/camera_specific",
|
||||
messages: [
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.detect.disabled",
|
||||
severity: "info",
|
||||
condition: (ctx) =>
|
||||
ctx.level === "camera" && ctx.formData?.enabled === false,
|
||||
},
|
||||
],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "fps-greater-than-five",
|
||||
@@ -12,6 +21,7 @@ const detect: SectionConfigOverrides = {
|
||||
position: "after",
|
||||
condition: (ctx) => {
|
||||
if (ctx.level !== "camera" || !ctx.fullCameraConfig) return false;
|
||||
if (ctx.fullCameraConfig.type === "lpr") return false;
|
||||
const detectFps = ctx.formData?.fps as number | undefined;
|
||||
const streamFps = ctx.fullCameraConfig.detect?.fps;
|
||||
return detectFps != null && streamFps != null && detectFps > 5;
|
||||
|
||||
@@ -53,6 +53,21 @@ const faceRecognition: SectionConfigOverrides = {
|
||||
"device",
|
||||
],
|
||||
restartRequired: ["enabled", "model_size", "device"],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "model-size-large",
|
||||
field: "model_size",
|
||||
messageKey: "configMessages.faceRecognition.modelSizeLarge",
|
||||
severity: "info",
|
||||
position: "after",
|
||||
condition: (ctx) => ctx.formData?.model_size === "large",
|
||||
},
|
||||
],
|
||||
uiSchema: {
|
||||
model_size: {
|
||||
"ui:options": { size: "xs", enumI18nPrefix: "modelSize" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -41,19 +41,12 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
"input_args",
|
||||
"hwaccel_args",
|
||||
"output_args",
|
||||
"path",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
hiddenFields: [],
|
||||
advancedFields: [
|
||||
"path",
|
||||
"global_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"path",
|
||||
"gpu",
|
||||
],
|
||||
hiddenFields: ["retry_interval"],
|
||||
advancedFields: ["path", "global_args", "gpu"],
|
||||
overrideFields: [
|
||||
"inputs",
|
||||
"path",
|
||||
@@ -61,7 +54,6 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
"input_args",
|
||||
"hwaccel_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
@@ -125,19 +117,10 @@ const ffmpeg: SectionConfigOverrides = {
|
||||
"global_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
advancedFields: [
|
||||
"global_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"path",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
advancedFields: ["global_args", "input_args", "output_args", "path", "gpu"],
|
||||
uiSchema: {
|
||||
path: {
|
||||
"ui:options": { size: "md" },
|
||||
|
||||
@@ -6,6 +6,16 @@ const logger: SectionConfigOverrides = {
|
||||
restartRequired: ["default", "logs"],
|
||||
fieldOrder: ["default", "logs"],
|
||||
advancedFields: ["logs"],
|
||||
uiSchema: {
|
||||
default: {
|
||||
"ui:options": { enumI18nPrefix: "logger.logLevel" },
|
||||
},
|
||||
logs: {
|
||||
additionalProperties: {
|
||||
"ui:options": { enumI18nPrefix: "logger.logLevel" },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -65,6 +65,16 @@ const lpr: SectionConfigOverrides = {
|
||||
"replace_rules",
|
||||
],
|
||||
restartRequired: ["model_size", "enhancement", "device"],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "model-size-large",
|
||||
field: "model_size",
|
||||
messageKey: "configMessages.lpr.modelSizeLarge",
|
||||
severity: "info",
|
||||
position: "after",
|
||||
condition: (ctx) => ctx.formData?.model_size === "large",
|
||||
},
|
||||
],
|
||||
uiSchema: {
|
||||
format: {
|
||||
"ui:options": { size: "md" },
|
||||
@@ -83,6 +93,9 @@ const lpr: SectionConfigOverrides = {
|
||||
suppressDescription: true,
|
||||
},
|
||||
},
|
||||
model_size: {
|
||||
"ui:options": { size: "xs", enumI18nPrefix: "modelSize" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1,8 +1,42 @@
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { SectionConfigOverrides } from "./types";
|
||||
|
||||
// Attribute labels (face, license_plate, Frigate+ couriers like DHL/Amazon,
|
||||
// etc.) are populated into objects.filters by the backend even when the
|
||||
// model can't actually detect them. They aren't user-settable, so hide any
|
||||
// `filters.<attr>` patterns from forms and override comparisons.
|
||||
const hideAttributeFilters = (config: FrigateConfig): string[] =>
|
||||
(config.model?.all_attributes ?? []).map((attr) => `filters.${attr}`);
|
||||
|
||||
const objects: SectionConfigOverrides = {
|
||||
base: {
|
||||
sectionDocs: "/configuration/object_filters",
|
||||
messages: [
|
||||
{
|
||||
key: "detect-disabled",
|
||||
messageKey: "configMessages.detect.disabled",
|
||||
severity: "info",
|
||||
condition: (ctx) =>
|
||||
ctx.level === "camera" &&
|
||||
ctx.fullCameraConfig?.detect?.enabled === false,
|
||||
},
|
||||
],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "genai-no-descriptions-provider",
|
||||
field: "genai.enabled",
|
||||
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
|
||||
severity: "warning",
|
||||
position: "before",
|
||||
condition: (ctx) => {
|
||||
const providers = ctx.fullConfig.genai;
|
||||
if (!providers || Object.keys(providers).length === 0) return true;
|
||||
return !Object.values(providers).some((agent) =>
|
||||
agent.roles?.includes("descriptions"),
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldDocs: {
|
||||
"filters.min_area": "/configuration/object_filters#object-area",
|
||||
"filters.max_area": "/configuration/object_filters#object-area",
|
||||
@@ -26,6 +60,7 @@ const objects: SectionConfigOverrides = {
|
||||
"filters.*.raw_mask",
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
hideAttributeFilters,
|
||||
],
|
||||
advancedFields: ["genai"],
|
||||
uiSchema: {
|
||||
@@ -99,6 +134,7 @@ const objects: SectionConfigOverrides = {
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
"genai.required_zones",
|
||||
hideAttributeFilters,
|
||||
],
|
||||
},
|
||||
camera: {
|
||||
@@ -123,6 +159,7 @@ const objects: SectionConfigOverrides = {
|
||||
"filters.*.raw_mask",
|
||||
"filters.mask",
|
||||
"filters.raw_mask",
|
||||
hideAttributeFilters,
|
||||
],
|
||||
advancedFields: [],
|
||||
},
|
||||
|
||||
@@ -39,6 +39,11 @@ const onvif: SectionConfigOverrides = {
|
||||
track: {
|
||||
"ui:widget": "objectLabels",
|
||||
},
|
||||
zooming: {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "onvif.autotracking.zooming",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -49,6 +49,17 @@ const record: SectionConfigOverrides = {
|
||||
"ui:options": { suppressMultiSchema: true, size: "lg" },
|
||||
},
|
||||
},
|
||||
"alerts.retain.mode": {
|
||||
"ui:options": { enumI18nPrefix: "retainMode" },
|
||||
},
|
||||
"detections.retain.mode": {
|
||||
"ui:options": { enumI18nPrefix: "retainMode" },
|
||||
},
|
||||
"preview.quality": {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "previewQuality",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
|
||||
@@ -41,6 +41,38 @@ const review: SectionConfigOverrides = {
|
||||
return !Array.isArray(labels) || labels.length === 0;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "genai-no-descriptions-provider",
|
||||
field: "genai.enabled",
|
||||
messageKey: "configMessages.objects.genaiNoDescriptionsProvider",
|
||||
severity: "warning",
|
||||
position: "before",
|
||||
condition: (ctx) => {
|
||||
const providers = ctx.fullConfig.genai;
|
||||
if (!providers || Object.keys(providers).length === 0) return true;
|
||||
return !Object.values(providers).some((agent) =>
|
||||
agent.roles?.includes("descriptions"),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: "genai-image-source-recordings-record-disabled",
|
||||
field: "genai.image_source",
|
||||
messageKey:
|
||||
"configMessages.review.genaiImageSourceRecordingsRecordDisabled",
|
||||
severity: "warning",
|
||||
position: "after",
|
||||
condition: (ctx) => {
|
||||
const genai = ctx.formData?.genai as
|
||||
| Record<string, unknown>
|
||||
| undefined;
|
||||
if (genai?.image_source !== "recordings") return false;
|
||||
if (ctx.level === "camera" && ctx.fullCameraConfig) {
|
||||
return ctx.fullCameraConfig.record?.enabled === false;
|
||||
}
|
||||
return ctx.fullConfig.record?.enabled === false;
|
||||
},
|
||||
},
|
||||
],
|
||||
fieldDocs: {
|
||||
"alerts.labels": "/configuration/review/#alerts-and-detections",
|
||||
@@ -98,6 +130,11 @@ const review: SectionConfigOverrides = {
|
||||
size: "full",
|
||||
},
|
||||
},
|
||||
image_source: {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "review.imageSource",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -18,10 +18,25 @@ const semanticSearch: SectionConfigOverrides = {
|
||||
advancedFields: ["reindex", "device"],
|
||||
restartRequired: ["enabled", "model", "model_size", "device"],
|
||||
hiddenFields: ["reindex"],
|
||||
fieldMessages: [
|
||||
{
|
||||
key: "jinav2-small-model-size",
|
||||
field: "model_size",
|
||||
messageKey: "configMessages.semanticSearch.jinav2SmallModelSize",
|
||||
severity: "warning",
|
||||
position: "after",
|
||||
condition: (ctx) =>
|
||||
ctx.formData?.model === "jinav2" &&
|
||||
ctx.formData?.model_size === "small",
|
||||
},
|
||||
],
|
||||
uiSchema: {
|
||||
model: {
|
||||
"ui:widget": "semanticSearchModel",
|
||||
},
|
||||
model_size: {
|
||||
"ui:options": { size: "xs", enumI18nPrefix: "modelSize" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -35,6 +35,11 @@ const snapshots: SectionConfigOverrides = {
|
||||
suppressMultiSchema: true,
|
||||
},
|
||||
},
|
||||
"retain.mode": {
|
||||
"ui:options": {
|
||||
enumI18nPrefix: "retainMode",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
global: {
|
||||
|
||||
@@ -23,6 +23,18 @@ const ui: SectionConfigOverrides = {
|
||||
timezone: {
|
||||
"ui:widget": "timezoneSelect",
|
||||
},
|
||||
time_format: {
|
||||
"ui:options": { enumI18nPrefix: "ui.timeFormat" },
|
||||
},
|
||||
date_style: {
|
||||
"ui:options": { enumI18nPrefix: "ui.TimeOrDateStyle" },
|
||||
},
|
||||
time_style: {
|
||||
"ui:options": { enumI18nPrefix: "ui.TimeOrDateStyle" },
|
||||
},
|
||||
unit_system: {
|
||||
"ui:options": { enumI18nPrefix: "ui.unitSystem" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -218,7 +218,7 @@ export default function CameraReviewClassification({
|
||||
<Label
|
||||
className={cn(
|
||||
"flex flex-row items-center text-base",
|
||||
alertsZonesModified && "text-danger",
|
||||
alertsZonesModified && "text-unsaved",
|
||||
)}
|
||||
>
|
||||
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
|
||||
@@ -286,7 +286,7 @@ export default function CameraReviewClassification({
|
||||
<Label
|
||||
className={cn(
|
||||
"flex flex-row items-center text-base",
|
||||
detectionsZonesModified && "text-danger",
|
||||
detectionsZonesModified && "text-unsaved",
|
||||
)}
|
||||
>
|
||||
<Trans ns="views/settings">
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
useRef,
|
||||
useContext,
|
||||
} from "react";
|
||||
import useSWR from "swr";
|
||||
import useSWR, { mutate as swrMutate } from "swr";
|
||||
import axios from "axios";
|
||||
import { toast } from "sonner";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -22,24 +22,22 @@ import {
|
||||
modifySchemaForSection,
|
||||
getEffectiveDefaultsForSection,
|
||||
sanitizeOverridesForSection,
|
||||
synthesizeMissingFilters,
|
||||
} from "./section-special-cases";
|
||||
import { getSectionValidation } from "../section-validations";
|
||||
import { useConfigOverride } from "@/hooks/use-config-override";
|
||||
import { CameraOverridesBadge } from "./CameraOverridesBadge";
|
||||
import { GlobalOverridesBadge } from "./GlobalOverridesBadge";
|
||||
import { ProfileOverridesBadge } from "./ProfileOverridesBadge";
|
||||
import { useSectionSchema } from "@/hooks/use-config-schema";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { LuChevronDown, LuChevronRight } from "react-icons/lu";
|
||||
import Heading from "@/components/ui/heading";
|
||||
import get from "lodash/get";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import merge from "lodash/merge";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
@@ -57,7 +55,11 @@ import {
|
||||
} from "@/components/ui/alert-dialog";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ConfigSectionData, JsonValue } from "@/types/configForm";
|
||||
import {
|
||||
ConfigSectionData,
|
||||
HiddenFieldEntry,
|
||||
JsonValue,
|
||||
} from "@/types/configForm";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
|
||||
import {
|
||||
@@ -65,10 +67,16 @@ import {
|
||||
globalCameraDefaultSections,
|
||||
buildOverrides,
|
||||
buildConfigDataForPath,
|
||||
flattenOverrides,
|
||||
getBaseCameraSectionValue,
|
||||
mergeProfileOverrides,
|
||||
resolveHiddenFieldEntries,
|
||||
sanitizeSectionData as sharedSanitizeSectionData,
|
||||
requiresRestartForOverrides as sharedRequiresRestartForOverrides,
|
||||
} from "@/utils/configUtil";
|
||||
import SaveAllPreviewPopover, {
|
||||
type SaveAllPreviewItem,
|
||||
} from "@/components/overlay/detail/SaveAllPreviewPopover";
|
||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||
import { useRestart } from "@/api/ws";
|
||||
import type {
|
||||
@@ -85,7 +93,7 @@ export interface SectionConfig {
|
||||
/** Fields to group together */
|
||||
fieldGroups?: Record<string, string[]>;
|
||||
/** Fields to hide from UI */
|
||||
hiddenFields?: string[];
|
||||
hiddenFields?: HiddenFieldEntry[];
|
||||
/** Fields to show in advanced section */
|
||||
advancedFields?: string[];
|
||||
/** Fields to compare for override detection */
|
||||
@@ -342,7 +350,10 @@ export function ConfigSection({
|
||||
`profiles.${profileName}.${sectionPath}`,
|
||||
);
|
||||
if (profileOverrides && typeof profileOverrides === "object") {
|
||||
return merge(cloneDeep(baseValue ?? {}), cloneDeep(profileOverrides));
|
||||
return mergeProfileOverrides(
|
||||
(baseValue as object) ?? {},
|
||||
profileOverrides as object,
|
||||
);
|
||||
}
|
||||
return baseValue;
|
||||
}
|
||||
@@ -352,25 +363,34 @@ export function ConfigSection({
|
||||
return get(config, sectionPath);
|
||||
}, [config, cameraName, sectionPath, effectiveLevel, profileName]);
|
||||
|
||||
const rawFormData = useMemo(() => {
|
||||
const rawFormData = useMemo<ConfigSectionData>(() => {
|
||||
if (!config) return {};
|
||||
|
||||
if (rawSectionValue === undefined || rawSectionValue === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
return rawSectionValue;
|
||||
}, [config, rawSectionValue]);
|
||||
return synthesizeMissingFilters(
|
||||
sectionPath,
|
||||
rawSectionValue,
|
||||
modifiedSchema ?? undefined,
|
||||
) as ConfigSectionData;
|
||||
}, [config, rawSectionValue, sectionPath, modifiedSchema]);
|
||||
|
||||
// When editing a profile, hide fields that require a restart since they
|
||||
// cannot take effect via profile switching alone.
|
||||
const effectiveHiddenFields = useMemo(() => {
|
||||
const base = resolveHiddenFieldEntries(sectionConfig.hiddenFields, config);
|
||||
if (!profileName || !sectionConfig.restartRequired?.length) {
|
||||
return sectionConfig.hiddenFields;
|
||||
return base;
|
||||
}
|
||||
const base = sectionConfig.hiddenFields ?? [];
|
||||
return [...new Set([...base, ...sectionConfig.restartRequired])];
|
||||
}, [profileName, sectionConfig.hiddenFields, sectionConfig.restartRequired]);
|
||||
}, [
|
||||
profileName,
|
||||
sectionConfig.hiddenFields,
|
||||
sectionConfig.restartRequired,
|
||||
config,
|
||||
]);
|
||||
|
||||
const sanitizeSectionData = useCallback(
|
||||
(data: ConfigSectionData) =>
|
||||
@@ -382,7 +402,7 @@ export function ConfigSection({
|
||||
const baseData = modifiedSchema
|
||||
? applySchemaDefaults(modifiedSchema, rawFormData)
|
||||
: rawFormData;
|
||||
return sanitizeSectionData(baseData);
|
||||
return sanitizeSectionData(baseData as ConfigSectionData);
|
||||
}, [rawFormData, modifiedSchema, sanitizeSectionData]);
|
||||
|
||||
const baselineSnapshot = useMemo(() => {
|
||||
@@ -738,6 +758,7 @@ export function ConfigSection({
|
||||
}
|
||||
|
||||
await refreshConfig();
|
||||
swrMutate("config/raw_paths");
|
||||
setPendingData(null);
|
||||
onSave?.();
|
||||
} catch (error) {
|
||||
@@ -913,6 +934,34 @@ export function ConfigSection({
|
||||
);
|
||||
}, [sectionConfig?.renderers, sectionPath, cameraName, setPendingData]);
|
||||
|
||||
// Build a flat list of pending field changes for this section only.
|
||||
// Mirrors the global Save All preview but scoped to the current section so
|
||||
// users can inspect what will be saved without leaving the section.
|
||||
const sectionPreviewItems = useMemo<SaveAllPreviewItem[]>(() => {
|
||||
if (!hasChanges) return [];
|
||||
if (!effectiveOverrides || typeof effectiveOverrides !== "object") {
|
||||
return [];
|
||||
}
|
||||
const flattened = flattenOverrides(effectiveOverrides as JsonValue);
|
||||
return flattened.map(({ path, value }) => ({
|
||||
scope: effectiveLevel,
|
||||
cameraName,
|
||||
profileName: profileName
|
||||
? (profileFriendlyName ?? profileName)
|
||||
: undefined,
|
||||
fieldPath: path ? `${sectionPath}.${path}` : sectionPath,
|
||||
value,
|
||||
}));
|
||||
}, [
|
||||
hasChanges,
|
||||
effectiveOverrides,
|
||||
effectiveLevel,
|
||||
cameraName,
|
||||
profileName,
|
||||
profileFriendlyName,
|
||||
sectionPath,
|
||||
]);
|
||||
|
||||
if (!modifiedSchema) {
|
||||
return null;
|
||||
}
|
||||
@@ -995,6 +1044,7 @@ export function ConfigSection({
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
restartRequired: sectionConfig.restartRequired,
|
||||
requiresRestart,
|
||||
isProfile: !!profileName,
|
||||
}}
|
||||
/>
|
||||
|
||||
@@ -1012,12 +1062,18 @@ export function ConfigSection({
|
||||
>
|
||||
{hasChanges && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">
|
||||
<span className="text-sm text-unsaved">
|
||||
{t("unsavedChanges", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "You have unsaved changes",
|
||||
})}
|
||||
</span>
|
||||
<SaveAllPreviewPopover
|
||||
items={sectionPreviewItems}
|
||||
className="h-7 w-7"
|
||||
align="start"
|
||||
side="top"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div className="flex w-full flex-col gap-2 sm:flex-row sm:items-center md:w-auto">
|
||||
@@ -1198,33 +1254,25 @@ export function ConfigSection({
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
(profileOverridesSection || isOverridden) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfig", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Base Config)",
|
||||
})
|
||||
: t("button.overriddenGlobal", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Global)",
|
||||
})}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfigTooltip", {
|
||||
ns: "views/settings",
|
||||
profile: profileFriendlyName ?? profileName,
|
||||
})
|
||||
: t("button.overriddenGlobalTooltip", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
(profileOverridesSection || isOverridden) &&
|
||||
cameraName &&
|
||||
(overrideSource === "profile" && profileName ? (
|
||||
<ProfileOverridesBadge
|
||||
sectionPath={sectionPath}
|
||||
cameraName={cameraName}
|
||||
profileName={profileName}
|
||||
profileFriendlyName={profileFriendlyName}
|
||||
profileBorderColor={profileBorderColor}
|
||||
/>
|
||||
) : (
|
||||
<GlobalOverridesBadge
|
||||
sectionPath={sectionPath}
|
||||
cameraName={cameraName}
|
||||
/>
|
||||
))}
|
||||
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{t("button.modified", {
|
||||
@@ -1261,45 +1309,29 @@ export function ConfigSection({
|
||||
<Heading as="h4">{title}</Heading>
|
||||
{showOverrideIndicator &&
|
||||
effectiveLevel === "camera" &&
|
||||
(profileOverridesSection || isOverridden) && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-default border-2 text-center text-xs text-primary-variant",
|
||||
overrideSource === "profile" && profileBorderColor
|
||||
? profileBorderColor
|
||||
: "border-selected",
|
||||
)}
|
||||
>
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfig", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Base Config)",
|
||||
})
|
||||
: t("button.overriddenGlobal", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Global)",
|
||||
})}
|
||||
</Badge>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{overrideSource === "profile"
|
||||
? t("button.overriddenBaseConfigTooltip", {
|
||||
ns: "views/settings",
|
||||
profile: profileFriendlyName ?? profileName,
|
||||
})
|
||||
: t("button.overriddenGlobalTooltip", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
(profileOverridesSection || isOverridden) &&
|
||||
cameraName &&
|
||||
(overrideSource === "profile" && profileName ? (
|
||||
<ProfileOverridesBadge
|
||||
sectionPath={sectionPath}
|
||||
cameraName={cameraName}
|
||||
profileName={profileName}
|
||||
profileFriendlyName={profileFriendlyName}
|
||||
profileBorderColor={profileBorderColor}
|
||||
/>
|
||||
) : (
|
||||
<GlobalOverridesBadge
|
||||
sectionPath={sectionPath}
|
||||
cameraName={cameraName}
|
||||
/>
|
||||
))}
|
||||
{showOverrideIndicator && effectiveLevel === "global" && (
|
||||
<CameraOverridesBadge sectionPath={sectionPath} />
|
||||
)}
|
||||
{hasChanges && (
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="cursor-default bg-danger text-xs text-white hover:bg-danger"
|
||||
className="cursor-default bg-unsaved text-xs text-black hover:bg-unsaved"
|
||||
>
|
||||
{t("button.modified", {
|
||||
ns: "common",
|
||||
|
||||
@@ -0,0 +1,259 @@
|
||||
import useSWR from "swr";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { LuChevronDown } from "react-icons/lu";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import {
|
||||
CameraOverrideEntry,
|
||||
FieldDelta,
|
||||
useCamerasOverridingSection,
|
||||
} from "@/hooks/use-config-override";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import type { ProfilesApiResponse } from "@/types/profile";
|
||||
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
|
||||
import { formatList } from "@/utils/stringUtil";
|
||||
import {
|
||||
getEffectiveHiddenFields,
|
||||
pathMatchesHiddenPattern,
|
||||
} from "@/utils/configUtil";
|
||||
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
|
||||
|
||||
const CAMERA_PAGE_BY_SECTION: Record<string, string> = {
|
||||
detect: "cameraDetect",
|
||||
ffmpeg: "cameraFfmpeg",
|
||||
record: "cameraRecording",
|
||||
snapshots: "cameraSnapshots",
|
||||
motion: "cameraMotion",
|
||||
objects: "cameraObjects",
|
||||
review: "cameraReview",
|
||||
audio: "cameraAudioEvents",
|
||||
audio_transcription: "cameraAudioTranscription",
|
||||
notifications: "cameraNotifications",
|
||||
live: "cameraLivePlayback",
|
||||
birdseye: "cameraBirdseye",
|
||||
face_recognition: "cameraFaceRecognition",
|
||||
lpr: "cameraLpr",
|
||||
timestamp_style: "cameraTimestampStyle",
|
||||
};
|
||||
|
||||
const MAX_FIELDS_PER_CAMERA = 5;
|
||||
|
||||
/**
|
||||
* Sections where the cross-camera override badge should be suppressed.
|
||||
* Includes enrichment sections that aren't meaningfully per-camera
|
||||
* (face recognition and LPR are intentionally omitted so the badge does show
|
||||
* there) and every System sub-page (detector hardware, database, networking,
|
||||
* etc.) which configures Frigate as a whole, not per-camera state.
|
||||
*/
|
||||
const SECTIONS_WITHOUT_OVERRIDE_BADGE = new Set([
|
||||
// Enrichments (face_recognition and lpr remain enabled)
|
||||
"semantic_search",
|
||||
"genai",
|
||||
"classification",
|
||||
"audio_transcription",
|
||||
// System
|
||||
"go2rtc_streams",
|
||||
"database",
|
||||
"mqtt",
|
||||
"tls",
|
||||
"auth",
|
||||
"networking",
|
||||
"proxy",
|
||||
"ui",
|
||||
"logger",
|
||||
"environment_vars",
|
||||
"telemetry",
|
||||
"birdseye",
|
||||
"detectors",
|
||||
"model",
|
||||
]);
|
||||
|
||||
type CameraEntryProps = {
|
||||
sectionPath: string;
|
||||
entry: CameraOverrideEntry;
|
||||
cameraPage?: string;
|
||||
};
|
||||
|
||||
type SourceGroup = {
|
||||
/** undefined → camera-level; string → profile name */
|
||||
profileName: string | undefined;
|
||||
deltas: FieldDelta[];
|
||||
};
|
||||
|
||||
function groupDeltasBySource(deltas: FieldDelta[]): SourceGroup[] {
|
||||
const cameraDeltas: FieldDelta[] = [];
|
||||
const byProfile = new Map<string, FieldDelta[]>();
|
||||
for (const delta of deltas) {
|
||||
if (delta.profileName) {
|
||||
const arr = byProfile.get(delta.profileName) ?? [];
|
||||
arr.push(delta);
|
||||
byProfile.set(delta.profileName, arr);
|
||||
} else {
|
||||
cameraDeltas.push(delta);
|
||||
}
|
||||
}
|
||||
const groups: SourceGroup[] = [];
|
||||
if (cameraDeltas.length > 0) {
|
||||
groups.push({ profileName: undefined, deltas: cameraDeltas });
|
||||
}
|
||||
for (const [profileName, group] of byProfile) {
|
||||
groups.push({ profileName, deltas: group });
|
||||
}
|
||||
return groups;
|
||||
}
|
||||
|
||||
function CameraEntry({ sectionPath, entry, cameraPage }: CameraEntryProps) {
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const fieldLabel = useOverrideFieldLabel(sectionPath);
|
||||
const friendlyName = useCameraFriendlyName(entry.camera);
|
||||
const { data: profilesData } = useSWR<ProfilesApiResponse>("profiles");
|
||||
|
||||
const profileFriendlyNames = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
profilesData?.profiles?.forEach((p) => map.set(p.name, p.friendly_name));
|
||||
return map;
|
||||
}, [profilesData]);
|
||||
|
||||
const formatDeltas = (deltas: FieldDelta[]) => {
|
||||
const visibleLabels = deltas
|
||||
.slice(0, MAX_FIELDS_PER_CAMERA)
|
||||
.map((delta) => fieldLabel(delta.fieldPath));
|
||||
const hiddenCount = deltas.length - visibleLabels.length;
|
||||
const labelsForList =
|
||||
hiddenCount > 0
|
||||
? [
|
||||
...visibleLabels,
|
||||
t("button.overriddenInCameras.othersField", {
|
||||
ns: "views/settings",
|
||||
count: hiddenCount,
|
||||
}),
|
||||
]
|
||||
: visibleLabels;
|
||||
return formatList(labelsForList);
|
||||
};
|
||||
|
||||
const groups = groupDeltasBySource(entry.fieldDeltas);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-0.5 text-xs">
|
||||
{cameraPage ? (
|
||||
<Link
|
||||
to={`/settings?page=${cameraPage}&camera=${encodeURIComponent(entry.camera)}`}
|
||||
className="font-medium hover:underline"
|
||||
>
|
||||
{friendlyName}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="font-medium">{friendlyName}</span>
|
||||
)}
|
||||
{groups.map((group) => (
|
||||
<span
|
||||
key={group.profileName ?? "__camera__"}
|
||||
className="ml-2 text-muted-foreground"
|
||||
>
|
||||
{group.profileName
|
||||
? t("button.overriddenInCameras.profilePrefix", {
|
||||
ns: "views/settings",
|
||||
profile:
|
||||
profileFriendlyNames.get(group.profileName) ??
|
||||
group.profileName,
|
||||
fields: formatDeltas(group.deltas),
|
||||
})
|
||||
: formatDeltas(group.deltas)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Props = {
|
||||
sectionPath: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function CameraOverridesBadge({ sectionPath, className }: Props) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const rawEntries = useCamerasOverridingSection(config, sectionPath);
|
||||
|
||||
const entries = useMemo(() => {
|
||||
const hiddenFields = getEffectiveHiddenFields(
|
||||
sectionPath,
|
||||
"global",
|
||||
config,
|
||||
);
|
||||
if (hiddenFields.length === 0) return rawEntries;
|
||||
return rawEntries
|
||||
.map((entry) => ({
|
||||
...entry,
|
||||
fieldDeltas: entry.fieldDeltas.filter(
|
||||
(delta) =>
|
||||
!hiddenFields.some((pattern) =>
|
||||
pathMatchesHiddenPattern(delta.fieldPath, pattern),
|
||||
),
|
||||
),
|
||||
}))
|
||||
.filter((entry) => entry.fieldDeltas.length > 0);
|
||||
}, [rawEntries, sectionPath, config]);
|
||||
|
||||
if (SECTIONS_WITHOUT_OVERRIDE_BADGE.has(sectionPath)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const cameraPage = CAMERA_PAGE_BY_SECTION[sectionPath];
|
||||
const count = entries.length;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={`cursor-pointer border-2 border-selected text-xs text-primary-variant ${className ?? ""}`}
|
||||
aria-label={t("button.overriddenInCameras.tooltip", {
|
||||
ns: "views/settings",
|
||||
count: count,
|
||||
})}
|
||||
>
|
||||
<span>
|
||||
{t("button.overriddenInCameras.label", {
|
||||
ns: "views/settings",
|
||||
count: count,
|
||||
})}
|
||||
</span>
|
||||
<LuChevronDown className="ml-1 size-3" />
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 max-w-[90vw] pr-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="pr-4 text-xs text-primary-variant">
|
||||
{t("button.overriddenInCameras.heading", {
|
||||
ns: "views/settings",
|
||||
count: count,
|
||||
})}
|
||||
</div>
|
||||
<div className="scrollbar-container flex max-h-[40dvh] flex-col gap-2 overflow-y-auto pr-4">
|
||||
{entries.map((entry) => (
|
||||
<CameraEntry
|
||||
key={entry.camera}
|
||||
sectionPath={sectionPath}
|
||||
entry={entry}
|
||||
cameraPage={cameraPage}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useCameraSectionDeltas } from "@/hooks/use-config-override";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { OverrideDeltaPopover } from "./OverrideDeltaPopover";
|
||||
|
||||
type Props = {
|
||||
sectionPath: string;
|
||||
cameraName: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function GlobalOverridesBadge({
|
||||
sectionPath,
|
||||
cameraName,
|
||||
className,
|
||||
}: Props) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const deltas = useCameraSectionDeltas(config, cameraName, sectionPath);
|
||||
|
||||
return (
|
||||
<OverrideDeltaPopover
|
||||
sectionPath={sectionPath}
|
||||
deltas={deltas}
|
||||
badgeLabel={t("button.overriddenGlobal", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Global)",
|
||||
})}
|
||||
ariaLabel={t("button.overriddenGlobalTooltip", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
heading={t("button.overriddenGlobalHeading", {
|
||||
ns: "views/settings",
|
||||
count: deltas.length,
|
||||
})}
|
||||
noDeltasMessage={t("button.overriddenGlobalNoDeltas", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { LuChevronDown } from "react-icons/lu";
|
||||
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import type { FieldDelta } from "@/hooks/use-config-override";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useOverrideFieldLabel } from "./useOverrideFieldLabel";
|
||||
|
||||
type Props = {
|
||||
sectionPath: string;
|
||||
deltas: FieldDelta[];
|
||||
/** Translated label shown inside the badge */
|
||||
badgeLabel: string;
|
||||
/** Accessible label for the badge trigger */
|
||||
ariaLabel: string;
|
||||
/** Heading rendered at the top of the popover content */
|
||||
heading: string;
|
||||
/** Message shown when there are zero field deltas */
|
||||
noDeltasMessage: string;
|
||||
/** Border color class for the badge (defaults to selected) */
|
||||
borderColorClass?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Shared popover layout for "this scope overrides these fields" badges
|
||||
* (e.g. profile overrides base config, camera overrides global config).
|
||||
*/
|
||||
export function OverrideDeltaPopover({
|
||||
sectionPath,
|
||||
deltas,
|
||||
badgeLabel,
|
||||
ariaLabel,
|
||||
heading,
|
||||
noDeltasMessage,
|
||||
borderColorClass,
|
||||
className,
|
||||
}: Props) {
|
||||
const fieldLabel = useOverrideFieldLabel(sectionPath);
|
||||
const count = deltas.length;
|
||||
|
||||
return (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className={cn(
|
||||
"cursor-pointer border-2 text-center text-xs text-primary-variant",
|
||||
borderColorClass ?? "border-selected",
|
||||
className,
|
||||
)}
|
||||
aria-label={ariaLabel}
|
||||
>
|
||||
<span>{badgeLabel}</span>
|
||||
<LuChevronDown className="ml-1 size-3" />
|
||||
</Badge>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent align="start" className="w-80 max-w-[90vw] pr-0">
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="pr-4 text-xs text-primary-variant">
|
||||
{count > 0 ? heading : noDeltasMessage}
|
||||
</div>
|
||||
{count > 0 && (
|
||||
<ul className="scrollbar-container ml-5 flex max-h-[40dvh] list-disc flex-col gap-1 overflow-y-auto pr-4 text-xs">
|
||||
{deltas.map((delta) => (
|
||||
<li key={delta.fieldPath}>{fieldLabel(delta.fieldPath)}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
import useSWR from "swr";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { useProfileSectionDeltas } from "@/hooks/use-config-override";
|
||||
import type { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { OverrideDeltaPopover } from "./OverrideDeltaPopover";
|
||||
|
||||
type Props = {
|
||||
sectionPath: string;
|
||||
cameraName: string;
|
||||
profileName: string;
|
||||
profileFriendlyName?: string;
|
||||
/** Border color class for profile-themed badge (e.g., "border-amber-500") */
|
||||
profileBorderColor?: string;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function ProfileOverridesBadge({
|
||||
sectionPath,
|
||||
cameraName,
|
||||
profileName,
|
||||
profileFriendlyName,
|
||||
profileBorderColor,
|
||||
className,
|
||||
}: Props) {
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const { t } = useTranslation(["views/settings"]);
|
||||
const deltas = useProfileSectionDeltas(
|
||||
config,
|
||||
cameraName,
|
||||
profileName,
|
||||
sectionPath,
|
||||
);
|
||||
|
||||
const displayProfile = profileFriendlyName ?? profileName;
|
||||
|
||||
return (
|
||||
<OverrideDeltaPopover
|
||||
sectionPath={sectionPath}
|
||||
deltas={deltas}
|
||||
badgeLabel={t("button.overriddenBaseConfig", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "Overridden (Base Config)",
|
||||
})}
|
||||
ariaLabel={t("button.overriddenBaseConfigTooltip", {
|
||||
ns: "views/settings",
|
||||
profile: displayProfile,
|
||||
})}
|
||||
heading={t("button.overriddenBaseConfigHeading", {
|
||||
ns: "views/settings",
|
||||
profile: displayProfile,
|
||||
count: deltas.length,
|
||||
})}
|
||||
noDeltasMessage={t("button.overriddenBaseConfigNoDeltas", {
|
||||
ns: "views/settings",
|
||||
profile: displayProfile,
|
||||
})}
|
||||
borderColorClass={profileBorderColor}
|
||||
className={className}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -15,7 +15,7 @@ import { JsonObject, JsonValue } from "@/types/configForm";
|
||||
* Sections that require special handling at the global level.
|
||||
* Add new section paths here as needed.
|
||||
*/
|
||||
const SPECIAL_CASE_SECTIONS = ["motion", "detectors"] as const;
|
||||
const SPECIAL_CASE_SECTIONS = ["motion", "detectors", "genai"] as const;
|
||||
|
||||
/**
|
||||
* Check if a section requires special case handling.
|
||||
@@ -53,6 +53,29 @@ export function modifySchemaForSection(
|
||||
return schemaWithoutDefault;
|
||||
}
|
||||
|
||||
if (sectionPath === "genai") {
|
||||
const additional = schema.additionalProperties;
|
||||
if (
|
||||
additional &&
|
||||
typeof additional === "object" &&
|
||||
!Array.isArray(additional)
|
||||
) {
|
||||
const props = (additional as RJSFSchema).properties;
|
||||
if (props && typeof props.provider === "object") {
|
||||
return {
|
||||
...schema,
|
||||
additionalProperties: {
|
||||
...additional,
|
||||
properties: {
|
||||
...props,
|
||||
provider: { ...(props.provider as object), default: "openai" },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
@@ -105,6 +128,60 @@ export function getEffectiveDefaultsForSection(
|
||||
return schemaDefaults;
|
||||
}
|
||||
|
||||
// Sections whose `filters` dict is keyed by a sibling list field. The backend
|
||||
// auto-populates these filters at config init but doesn't re-run after profile
|
||||
// merges, so we synthesize the missing entries on the frontend.
|
||||
const FILTER_SECTIONS: Record<string, { listField: string }> = {
|
||||
objects: { listField: "track" },
|
||||
audio: { listField: "listen" },
|
||||
};
|
||||
|
||||
/**
|
||||
* Add default filter entries for any label in the section's list field
|
||||
* (e.g. `objects.track`, `audio.listen`) that isn't already in `filters`, so
|
||||
* each label gets a collapsible. The backend only auto-populates filters at
|
||||
* config init, not after profile merges.
|
||||
*/
|
||||
export function synthesizeMissingFilters(
|
||||
sectionPath: string,
|
||||
data: unknown,
|
||||
sectionSchema: RJSFSchema | undefined,
|
||||
): unknown {
|
||||
const sectionConfig = FILTER_SECTIONS[sectionPath];
|
||||
if (!sectionConfig) return data;
|
||||
if (!isJsonObject(data)) return data;
|
||||
|
||||
const listValue = (data as JsonObject)[sectionConfig.listField];
|
||||
if (!Array.isArray(listValue) || listValue.length === 0) return data;
|
||||
|
||||
const properties = (sectionSchema as { properties?: Record<string, unknown> })
|
||||
?.properties;
|
||||
const filtersSchema = isJsonObject(properties)
|
||||
? (properties.filters as { additionalProperties?: unknown } | undefined)
|
||||
: undefined;
|
||||
const filterEntrySchema = isJsonObject(filtersSchema?.additionalProperties)
|
||||
? (filtersSchema.additionalProperties as RJSFSchema)
|
||||
: undefined;
|
||||
|
||||
const existingFilters = isJsonObject((data as JsonObject).filters)
|
||||
? ((data as JsonObject).filters as JsonObject)
|
||||
: {};
|
||||
|
||||
const newFilters: JsonObject = { ...existingFilters };
|
||||
let added = false;
|
||||
for (const label of listValue) {
|
||||
if (typeof label !== "string") continue;
|
||||
if (Object.prototype.hasOwnProperty.call(newFilters, label)) continue;
|
||||
newFilters[label] = (
|
||||
filterEntrySchema ? applySchemaDefaults(filterEntrySchema, {}) : {}
|
||||
) as JsonValue;
|
||||
added = true;
|
||||
}
|
||||
|
||||
if (!added) return data;
|
||||
return { ...(data as JsonObject), filters: newFilters };
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize overrides payloads for section-specific quirks.
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { humanizeKey } from "@/components/config-form/theme/utils/i18n";
|
||||
|
||||
/**
|
||||
* Resolve a translated label for a config field path within a section, falling
|
||||
* back through reduced paths (dropping each intermediate segment in turn) so
|
||||
* dict-keyed paths like `filters.person.threshold` still surface a meaningful
|
||||
* label. Dropped segments are prepended as context (e.g. "Person · Threshold").
|
||||
*
|
||||
* Shared between override badges that need to render field labels (e.g.
|
||||
* CameraOverridesBadge, ProfileOverridesBadge).
|
||||
*/
|
||||
export function useOverrideFieldLabel(sectionPath: string) {
|
||||
const { t, i18n } = useTranslation([
|
||||
"config/global",
|
||||
"views/settings",
|
||||
"objects",
|
||||
]);
|
||||
|
||||
return (fieldPath: string): string => {
|
||||
if (!fieldPath) {
|
||||
const sectionKey = `${sectionPath}.label`;
|
||||
return i18n.exists(sectionKey, { ns: "config/global" })
|
||||
? t(sectionKey, { ns: "config/global" })
|
||||
: humanizeKey(sectionPath);
|
||||
}
|
||||
|
||||
const segments = fieldPath.split(".");
|
||||
|
||||
const fullKey = `${sectionPath}.${fieldPath}.label`;
|
||||
if (i18n.exists(fullKey, { ns: "config/global" })) {
|
||||
return t(fullKey, { ns: "config/global" });
|
||||
}
|
||||
|
||||
for (let i = 0; i < segments.length; i++) {
|
||||
const reduced = [...segments.slice(0, i), ...segments.slice(i + 1)].join(
|
||||
".",
|
||||
);
|
||||
if (!reduced) continue;
|
||||
const reducedKey = `${sectionPath}.${reduced}.label`;
|
||||
if (i18n.exists(reducedKey, { ns: "config/global" })) {
|
||||
const resolvedLabel = t(reducedKey, { ns: "config/global" });
|
||||
const dropped = segments[i];
|
||||
const droppedLabel = i18n.exists(dropped, { ns: "objects" })
|
||||
? t(dropped, { ns: "objects" })
|
||||
: humanizeKey(dropped);
|
||||
return `${droppedLabel} · ${resolvedLabel}`;
|
||||
}
|
||||
}
|
||||
|
||||
return humanizeKey(segments[segments.length - 1]);
|
||||
};
|
||||
}
|
||||
@@ -3,6 +3,8 @@ import yaml from "js-yaml";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ConfigFormContext } from "@/types/configForm";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
function formatYaml(value: unknown): string {
|
||||
if (
|
||||
@@ -45,8 +47,14 @@ function parseYaml(text: string): {
|
||||
}
|
||||
|
||||
export function DictAsYamlField(props: FieldProps) {
|
||||
const { formData, onChange, readonly, disabled, idSchema, schema } = props;
|
||||
|
||||
const { formData, onChange, readonly, disabled, idSchema, schema, registry } =
|
||||
props;
|
||||
const formContext = registry.formContext as ConfigFormContext | undefined;
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
(formContext?.level === "camera" ? "config/cameras" : "config/global");
|
||||
const { t: fallbackT } = useTranslation(["common", configNamespace]);
|
||||
const t = formContext?.t ?? fallbackT;
|
||||
const emptyPath = useMemo(() => [] as FieldPathList, []);
|
||||
const fieldPath =
|
||||
(props as { fieldPathId?: { path?: FieldPathList } }).fieldPathId?.path ??
|
||||
@@ -94,12 +102,16 @@ export function DictAsYamlField(props: FieldProps) {
|
||||
);
|
||||
|
||||
const id = idSchema?.$id ?? props.name;
|
||||
const sectionPrefix = formContext?.sectionI18nPrefix;
|
||||
|
||||
const title = t(`${sectionPrefix}.${id}.label`) ?? schema.title;
|
||||
const description =
|
||||
t(`${sectionPrefix}.${id}.description`) ?? schema.description;
|
||||
return (
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{schema.title && (
|
||||
{title && (
|
||||
<label htmlFor={id} className="text-sm font-medium">
|
||||
{schema.title}
|
||||
{title}
|
||||
</label>
|
||||
)}
|
||||
<Textarea
|
||||
@@ -114,8 +126,8 @@ export function DictAsYamlField(props: FieldProps) {
|
||||
onBlur={handleBlur}
|
||||
/>
|
||||
{error && <p className="text-xs text-destructive">{error}</p>}
|
||||
{schema.description && (
|
||||
<p className="text-xs text-muted-foreground">{schema.description}</p>
|
||||
{description && (
|
||||
<p className="text-xs text-muted-foreground">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -28,7 +28,11 @@ export function KnownPlatesField(props: FieldProps) {
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
(formContext?.level === "camera" ? "config/cameras" : "config/global");
|
||||
const { t: fallbackT } = useTranslation(["common", configNamespace]);
|
||||
const t = formContext?.t ?? fallbackT;
|
||||
|
||||
const data: KnownPlatesData = useMemo(() => {
|
||||
if (!formData || typeof formData !== "object" || Array.isArray(formData)) {
|
||||
@@ -39,8 +43,14 @@ export function KnownPlatesField(props: FieldProps) {
|
||||
|
||||
const entries = useMemo(() => Object.entries(data), [data]);
|
||||
|
||||
const title = (schema as RJSFSchema).title;
|
||||
const description = (schema as RJSFSchema).description;
|
||||
const id = idSchema?.$id ?? props.name;
|
||||
const sectionPrefix = formContext?.sectionI18nPrefix;
|
||||
|
||||
const title =
|
||||
t(`${sectionPrefix}.${id}.label`) ?? (schema as RJSFSchema).title;
|
||||
const description =
|
||||
t(`${sectionPrefix}.${id}.description`) ??
|
||||
(schema as RJSFSchema).description;
|
||||
|
||||
const hasItems = entries.length > 0;
|
||||
const emptyPath = useMemo(() => [] as FieldPathList, []);
|
||||
@@ -154,7 +164,7 @@ export function KnownPlatesField(props: FieldProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle
|
||||
className={cn("text-sm", isModified && "text-danger")}
|
||||
className={cn("text-sm", isModified && "text-unsaved")}
|
||||
>
|
||||
{title}
|
||||
</CardTitle>
|
||||
|
||||
@@ -47,7 +47,11 @@ export function ReplaceRulesField(props: FieldProps) {
|
||||
| ConfigFormContext
|
||||
| undefined;
|
||||
|
||||
const { t } = useTranslation(["common"]);
|
||||
const configNamespace =
|
||||
formContext?.i18nNamespace ??
|
||||
(formContext?.level === "camera" ? "config/cameras" : "config/global");
|
||||
const { t: fallbackT } = useTranslation(["common", configNamespace]);
|
||||
const t = formContext?.t ?? fallbackT;
|
||||
|
||||
const rules: ReplaceRule[] = useMemo(() => {
|
||||
if (!Array.isArray(formData)) {
|
||||
@@ -60,10 +64,21 @@ export function ReplaceRulesField(props: FieldProps) {
|
||||
() => getItemSchema(schema as RJSFSchema),
|
||||
[schema],
|
||||
);
|
||||
const title = (schema as RJSFSchema).title;
|
||||
const description = (schema as RJSFSchema).description;
|
||||
const patternTitle = getPropertyTitle(itemSchema, "pattern");
|
||||
const replacementTitle = getPropertyTitle(itemSchema, "replacement");
|
||||
|
||||
const id = idSchema?.$id ?? props.name;
|
||||
const sectionPrefix = formContext?.sectionI18nPrefix;
|
||||
|
||||
const title =
|
||||
t(`${sectionPrefix}.${id}.label`) ?? (schema as RJSFSchema).title;
|
||||
const description =
|
||||
t(`${sectionPrefix}.${id}.description`) ??
|
||||
(schema as RJSFSchema).description;
|
||||
const patternTitle =
|
||||
t(`${sectionPrefix}.${id}.pattern.label`) ??
|
||||
getPropertyTitle(itemSchema, "pattern");
|
||||
const replacementTitle =
|
||||
t(`${sectionPrefix}.${id}.replacement.label`) ??
|
||||
getPropertyTitle(itemSchema, "replacement");
|
||||
|
||||
const hasItems = rules.length > 0;
|
||||
const emptyPath = useMemo(() => [] as FieldPathList, []);
|
||||
@@ -142,7 +157,7 @@ export function ReplaceRulesField(props: FieldProps) {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<CardTitle
|
||||
className={cn("text-sm", isModified && "text-danger")}
|
||||
className={cn("text-sm", isModified && "text-unsaved")}
|
||||
>
|
||||
{title}
|
||||
</CardTitle>
|
||||
|
||||
@@ -497,7 +497,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isModified && "text-danger",
|
||||
isModified && "text-unsaved",
|
||||
hasFieldErrors && "text-destructive",
|
||||
)}
|
||||
>
|
||||
@@ -516,7 +516,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
return (
|
||||
<Label
|
||||
htmlFor={id}
|
||||
className={cn("text-sm font-medium", isModified && "text-danger")}
|
||||
className={cn("text-sm font-medium", isModified && "text-unsaved")}
|
||||
>
|
||||
{finalLabel}
|
||||
{required && <span className="ml-1 text-destructive">*</span>}
|
||||
@@ -535,7 +535,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
htmlFor={id}
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
isModified && "text-danger",
|
||||
isModified && "text-unsaved",
|
||||
hasFieldErrors && "text-destructive",
|
||||
)}
|
||||
>
|
||||
|
||||
@@ -52,12 +52,11 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
} = props;
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
|
||||
// Check if this is a root-level object
|
||||
const isRoot = registry?.rootSchema === schema;
|
||||
const overrides = formContext?.overrides;
|
||||
const baselineFormData = formContext?.baselineFormData;
|
||||
const hiddenFields = formContext?.hiddenFields;
|
||||
const fieldPath = props.fieldPathId.path;
|
||||
const isRoot = fieldPath.length === 0;
|
||||
const restartRequired = formContext?.restartRequired;
|
||||
const defaultRequiresRestart = formContext?.requiresRestart ?? true;
|
||||
|
||||
@@ -211,6 +210,9 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
(p.content.props as RjsfElementProps).uiSchema?.["ui:options"]
|
||||
?.advanced !== true,
|
||||
);
|
||||
|
||||
const isAudioLabels = uiSchema?.["ui:options"]?.isAudioLabels === true;
|
||||
|
||||
const hasModifiedAdvanced = advancedProps.some((prop) =>
|
||||
checkSubtreeModified([...fieldPath, prop.name]),
|
||||
);
|
||||
@@ -244,7 +246,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
const path = fieldPathId?.path;
|
||||
const filterObjectLabel = path ? getFilterObjectLabel(path) : undefined;
|
||||
const translatedFilterLabel = filterObjectLabel
|
||||
? getTranslatedLabel(filterObjectLabel, "object")
|
||||
? getTranslatedLabel(filterObjectLabel, isAudioLabels ? "audio" : "object")
|
||||
: undefined;
|
||||
if (path) {
|
||||
translationPath = buildTranslationPath(
|
||||
@@ -467,7 +469,7 @@ export function ObjectFieldTemplate(props: ObjectFieldTemplateProps) {
|
||||
<CardTitle
|
||||
className={cn(
|
||||
"flex items-center text-sm",
|
||||
hasModifiedDescendants && "text-danger",
|
||||
hasModifiedDescendants && "text-unsaved",
|
||||
)}
|
||||
>
|
||||
{inferredLabel}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
import { WidgetProps } from "@rjsf/utils";
|
||||
import { SwitchesWidget } from "./SwitchesWidget";
|
||||
import { FormContext } from "./SwitchesWidget";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import i18n, { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { JsonObject } from "@/types/configForm";
|
||||
|
||||
@@ -76,7 +76,12 @@ function getObjectLabels(context: FormContext): string[] {
|
||||
...sourceLabels,
|
||||
...formDataLabels,
|
||||
]);
|
||||
return [...combinedLabels].sort();
|
||||
return [...combinedLabels].sort((a, b) =>
|
||||
getObjectLabelDisplayName(a).localeCompare(
|
||||
getObjectLabelDisplayName(b),
|
||||
i18n.language,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function getObjectLabelDisplayName(label: string): string {
|
||||
@@ -94,6 +99,7 @@ export function ObjectLabelSwitchesWidget(props: WidgetProps) {
|
||||
i18nKey: "objectLabels",
|
||||
listClassName:
|
||||
"relative max-h-none overflow-visible md:max-h-64 md:overflow-y-auto md:overscroll-contain md:scrollbar-container",
|
||||
enableSearch: true,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,6 +6,7 @@ import { getWidget } from "@rjsf/utils";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { getNonNullSchema } from "../fields/nullableUtils";
|
||||
import type { ConfigFormContext } from "@/types/configForm";
|
||||
|
||||
export function OptionalFieldWidget(props: WidgetProps) {
|
||||
const { id, value, disabled, readonly, onChange, schema, options, registry } =
|
||||
@@ -13,6 +14,8 @@ export function OptionalFieldWidget(props: WidgetProps) {
|
||||
|
||||
const innerWidgetName = (options.innerWidget as string) || undefined;
|
||||
const isEnabled = value !== undefined && value !== null;
|
||||
const formContext = registry?.formContext as ConfigFormContext | undefined;
|
||||
const isProfile = !!formContext?.isProfile;
|
||||
|
||||
// Extract the non-null branch from anyOf [Type, null]
|
||||
const innerSchema = getNonNullSchema(schema) ?? schema;
|
||||
@@ -42,10 +45,17 @@ export function OptionalFieldWidget(props: WidgetProps) {
|
||||
const innerProps: WidgetProps = {
|
||||
...props,
|
||||
schema: innerSchema,
|
||||
disabled: disabled || readonly || !isEnabled,
|
||||
disabled: disabled || readonly || (!isProfile && !isEnabled),
|
||||
value: isEnabled ? value : getDefaultValue(),
|
||||
};
|
||||
|
||||
// don't show the switch if we're editing in a profile
|
||||
// to disable in a profile, users should edit the config manually, eg:
|
||||
// skip_motion_threshold: None
|
||||
if (isProfile) {
|
||||
return <InnerWidget {...innerProps} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
|
||||
@@ -592,7 +592,7 @@ export function CameraGroupRow({
|
||||
|
||||
{isMobile && !isReadOnly && (
|
||||
<>
|
||||
<DropdownMenu modal={!isDesktop}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<HiOutlineDotsVertical className="size-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -136,7 +136,6 @@ export function CamerasFilterButton({
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
|
||||
@@ -81,7 +81,7 @@ export default function AccountSettings({ className }: AccountSettingsProps) {
|
||||
};
|
||||
|
||||
return (
|
||||
<Container modal={!isDesktop}>
|
||||
<Container>
|
||||
<Tooltip>
|
||||
<Trigger asChild>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
LuLifeBuoy,
|
||||
LuList,
|
||||
LuLogOut,
|
||||
LuMessageSquare,
|
||||
LuMoon,
|
||||
LuSquarePen,
|
||||
LuScanFace,
|
||||
@@ -92,6 +93,14 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
useSWR<ProfilesApiResponse>("profiles");
|
||||
const logoutUrl = config?.proxy?.logout_url || "/api/logout";
|
||||
|
||||
const hasChatAgent = useMemo(
|
||||
() =>
|
||||
Object.values(config?.genai ?? {}).some((agent) =>
|
||||
agent?.roles?.includes("chat"),
|
||||
),
|
||||
[config?.genai],
|
||||
);
|
||||
|
||||
// languages
|
||||
|
||||
const languages = useMemo(() => {
|
||||
@@ -205,7 +214,7 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Container modal={!isDesktop}>
|
||||
<Container>
|
||||
<Trigger>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
@@ -482,21 +491,25 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{isAdmin && isMobile && config?.face_recognition.enabled && (
|
||||
<>
|
||||
<Link to="/faces">
|
||||
<MenuItem
|
||||
className="flex w-full items-center p-2 text-sm"
|
||||
aria-label={t("menu.faceLibrary")}
|
||||
>
|
||||
<LuScanFace className="mr-2 size-4" />
|
||||
<span>{t("menu.faceLibrary")}</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{isAdmin && isMobile && (
|
||||
<>
|
||||
</DropdownMenuGroup>
|
||||
{isMobile && isAdmin && (
|
||||
<>
|
||||
<DropdownMenuLabel className="mt-1">
|
||||
{t("menu.features")}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuGroup className="flex flex-col">
|
||||
{config?.face_recognition.enabled && (
|
||||
<Link to="/faces">
|
||||
<MenuItem
|
||||
className="flex w-full items-center p-2 text-sm"
|
||||
aria-label={t("menu.faceLibrary")}
|
||||
>
|
||||
<LuScanFace className="mr-2 size-4" />
|
||||
<span>{t("menu.faceLibrary")}</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
)}
|
||||
<Link to="/classification">
|
||||
<MenuItem
|
||||
className="flex w-full items-center p-2 text-sm"
|
||||
@@ -506,9 +519,20 @@ export default function GeneralSettings({ className }: GeneralSettingsProps) {
|
||||
<span>{t("menu.classification")}</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
{hasChatAgent && (
|
||||
<Link to="/chat">
|
||||
<MenuItem
|
||||
className="flex w-full items-center p-2 text-sm"
|
||||
aria-label={t("menu.chat")}
|
||||
>
|
||||
<LuMessageSquare className="mr-2 size-4" />
|
||||
<span>{t("menu.chat")}</span>
|
||||
</MenuItem>
|
||||
</Link>
|
||||
)}
|
||||
</DropdownMenuGroup>
|
||||
</>
|
||||
)}
|
||||
<DropdownMenuLabel className={isDesktop ? "mt-3" : "mt-1"}>
|
||||
{t("menu.appearance")}
|
||||
</DropdownMenuLabel>
|
||||
|
||||
@@ -279,7 +279,7 @@ export default function LiveContextMenu({
|
||||
|
||||
return (
|
||||
<div className={cn("w-full", className)}>
|
||||
<ContextMenu key={camera} modal={false} onOpenChange={handleOpenChange}>
|
||||
<ContextMenu key={camera} onOpenChange={handleOpenChange}>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>
|
||||
<div className="flex flex-col items-start gap-1 py-1 pl-2">
|
||||
|
||||
@@ -98,10 +98,7 @@ export default function SearchResultActions({
|
||||
end_time: event.end_time,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
navigate("/replay");
|
||||
}
|
||||
})
|
||||
@@ -258,13 +255,13 @@ export default function SearchResultActions({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
{isContextMenu ? (
|
||||
<ContextMenu modal={false}>
|
||||
<ContextMenu>
|
||||
<ContextMenuTrigger>{children}</ContextMenuTrigger>
|
||||
<ContextMenuContent>{menuItems}</ContextMenuContent>
|
||||
</ContextMenu>
|
||||
) : (
|
||||
<>
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<BlurredIconButton aria-label={t("itemMenu.more.aria")}>
|
||||
<FiMoreVertical className="size-5" />
|
||||
|
||||
@@ -22,7 +22,7 @@ export default function ActionsDropdown({
|
||||
const { t } = useTranslation(["components/dialog", "views/replay", "common"]);
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
className="flex items-center gap-2"
|
||||
|
||||
@@ -102,6 +102,19 @@ export default function ClassificationSelectionDialog({
|
||||
// control
|
||||
const [newClass, setNewClass] = useState(false);
|
||||
|
||||
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
|
||||
// scroll containers, so attach a non-passive listener that scrolls manually.
|
||||
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (!el || !isDesktop) return;
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (el.scrollHeight <= el.clientHeight) return;
|
||||
e.preventDefault();
|
||||
el.scrollTop += e.deltaY;
|
||||
};
|
||||
el.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handleWheel);
|
||||
}, []);
|
||||
|
||||
// components
|
||||
const Selector = isDesktop ? DropdownMenu : Drawer;
|
||||
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||
@@ -114,6 +127,8 @@ export default function ClassificationSelectionDialog({
|
||||
</DrawerClose>
|
||||
);
|
||||
|
||||
// keep modal false on desktop to prevent dismissable layer pointer events
|
||||
// issue with dialog auto-close
|
||||
return (
|
||||
<div className={className ?? "flex"}>
|
||||
<TextEntryDialog
|
||||
@@ -122,60 +137,60 @@ export default function ClassificationSelectionDialog({
|
||||
title={t("createCategory.new")}
|
||||
onSave={(newCat) => onCategorizeImage(newCat)}
|
||||
/>
|
||||
|
||||
<Tooltip>
|
||||
<Selector>
|
||||
<SelectorTrigger asChild>
|
||||
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
|
||||
</SelectorTrigger>
|
||||
<SelectorContent
|
||||
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isMobile && (
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Details</DrawerTitle>
|
||||
<DrawerDescription>Details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
)}
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("categorizeImageAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[40dvh] flex-col overflow-y-auto",
|
||||
isMobile && "gap-2 pb-4",
|
||||
)}
|
||||
<Selector {...(isDesktop ? { modal: false } : {})}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={isChildButton}>
|
||||
<SelectorTrigger asChild>{children}</SelectorTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
{tooltipLabel ?? t("categorizeImage")}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectorContent
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
|
||||
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
|
||||
)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isMobile && (
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Details</DrawerTitle>
|
||||
<DrawerDescription>Details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
)}
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("categorizeImageAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div className={cn("flex flex-col", isMobile && "gap-2 pb-4")}>
|
||||
{filteredClasses
|
||||
.sort((a, b) => {
|
||||
if (a === "none") return 1;
|
||||
if (b === "none") return -1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((category) => (
|
||||
<SelectorItem
|
||||
key={category}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onCategorizeImage(category)}
|
||||
>
|
||||
{category === "none"
|
||||
? t("details.none")
|
||||
: category.replaceAll("_", " ")}
|
||||
</SelectorItem>
|
||||
))}
|
||||
<Separator />
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewClass(true)}
|
||||
>
|
||||
{filteredClasses
|
||||
.sort((a, b) => {
|
||||
if (a === "none") return 1;
|
||||
if (b === "none") return -1;
|
||||
return a.localeCompare(b);
|
||||
})
|
||||
.map((category) => (
|
||||
<SelectorItem
|
||||
key={category}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onCategorizeImage(category)}
|
||||
>
|
||||
{category === "none"
|
||||
? t("details.none")
|
||||
: category.replaceAll("_", " ")}
|
||||
</SelectorItem>
|
||||
))}
|
||||
<Separator />
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewClass(true)}
|
||||
>
|
||||
{t("createCategory.new")}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
<TooltipContent>{tooltipLabel ?? t("categorizeImage")}</TooltipContent>
|
||||
</Tooltip>
|
||||
{t("createCategory.new")}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -127,7 +127,7 @@ export function CustomTimeSelector({
|
||||
{formattedStart}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-center" disablePortal>
|
||||
<PopoverContent className="flex flex-col items-center">
|
||||
<TimezoneAwareCalendar
|
||||
timezone={config?.ui.timezone}
|
||||
selectedDay={new Date(startTime * 1000)}
|
||||
@@ -193,7 +193,7 @@ export function CustomTimeSelector({
|
||||
{formattedEnd}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="flex flex-col items-center" disablePortal>
|
||||
<PopoverContent className="flex flex-col items-center">
|
||||
<TimezoneAwareCalendar
|
||||
timezone={config?.ui.timezone}
|
||||
selectedDay={new Date(endTime * 1000)}
|
||||
|
||||
@@ -209,10 +209,7 @@ export default function DebugReplayDialog({
|
||||
end_time: range.before,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success"), {
|
||||
position: "top-center",
|
||||
});
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
setMode("none");
|
||||
setRange(undefined);
|
||||
navigate("/replay");
|
||||
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { cn } from "@/lib/utils";
|
||||
import React, { ReactNode, useMemo, useState } from "react";
|
||||
import React, { ReactNode, useCallback, useMemo, useState } from "react";
|
||||
import TextEntryDialog from "./dialog/TextEntryDialog";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
@@ -61,6 +61,19 @@ export default function FaceSelectionDialog({
|
||||
// control
|
||||
const [newFace, setNewFace] = useState(false);
|
||||
|
||||
// Non-modal Radix DropdownMenu doesn't propagate wheel events to nested
|
||||
// scroll containers, so attach a non-passive listener that scrolls manually.
|
||||
const scrollContainerRef = useCallback((el: HTMLDivElement | null) => {
|
||||
if (!el || !isDesktop) return;
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
if (el.scrollHeight <= el.clientHeight) return;
|
||||
e.preventDefault();
|
||||
el.scrollTop += e.deltaY;
|
||||
};
|
||||
el.addEventListener("wheel", handleWheel, { passive: false });
|
||||
return () => el.removeEventListener("wheel", handleWheel);
|
||||
}, []);
|
||||
|
||||
// components
|
||||
const Selector = isDesktop ? DropdownMenu : Drawer;
|
||||
const SelectorTrigger = isDesktop ? DropdownMenuTrigger : DrawerTrigger;
|
||||
@@ -73,6 +86,8 @@ export default function FaceSelectionDialog({
|
||||
</DrawerClose>
|
||||
);
|
||||
|
||||
// keep modal false on desktop to prevent dismissable layer pointer events
|
||||
// issue with dialog auto-close
|
||||
return (
|
||||
<div className={className ?? "flex"}>
|
||||
{newFace && (
|
||||
@@ -83,52 +98,56 @@ export default function FaceSelectionDialog({
|
||||
onSave={(newName) => onTrainAttempt(newName)}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Tooltip>
|
||||
<Selector>
|
||||
<SelectorTrigger asChild>
|
||||
<TooltipTrigger asChild={isChildButton}>{children}</TooltipTrigger>
|
||||
</SelectorTrigger>
|
||||
<SelectorContent
|
||||
className={cn("", isMobile && "mx-1 gap-2 rounded-t-2xl px-4")}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isMobile && (
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Details</DrawerTitle>
|
||||
<DrawerDescription>Details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<Selector {...(isDesktop ? { modal: false } : {})}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild={isChildButton}>
|
||||
<SelectorTrigger asChild>{children}</SelectorTrigger>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
|
||||
</Tooltip>
|
||||
<SelectorContent
|
||||
ref={scrollContainerRef}
|
||||
className={cn(
|
||||
isDesktop && "scrollbar-container max-h-[40dvh] overflow-y-auto",
|
||||
isMobile && "mx-1 gap-2 rounded-t-2xl px-4",
|
||||
)}
|
||||
onCloseAutoFocus={(e) => e.preventDefault()}
|
||||
>
|
||||
{isMobile && (
|
||||
<DrawerHeader className="sr-only">
|
||||
<DrawerTitle>Details</DrawerTitle>
|
||||
<DrawerDescription>Details</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
)}
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("trainFaceAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"flex flex-col",
|
||||
isMobile &&
|
||||
"max-h-[40dvh] gap-2 overflow-y-auto overflow-x-hidden pb-4",
|
||||
)}
|
||||
<DropdownMenuLabel>
|
||||
{dialogLabel ?? t("trainFaceAs")}
|
||||
</DropdownMenuLabel>
|
||||
<div
|
||||
className={cn(
|
||||
"flex max-h-[40dvh] flex-col overflow-y-auto overflow-x-hidden",
|
||||
isMobile && "gap-2 pb-4",
|
||||
)}
|
||||
>
|
||||
{filteredNames.sort().map((faceName) => (
|
||||
<SelectorItem
|
||||
key={faceName}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => onTrainAttempt(faceName)}
|
||||
>
|
||||
{faceName}
|
||||
</SelectorItem>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
>
|
||||
{filteredNames.sort().map((faceName) => (
|
||||
<SelectorItem
|
||||
key={faceName}
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewFace(true)}
|
||||
onClick={() => onTrainAttempt(faceName)}
|
||||
>
|
||||
{t("createFaceLibrary.new")}
|
||||
{faceName}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
<TooltipContent>{tooltipLabel ?? t("trainFace")}</TooltipContent>
|
||||
</Tooltip>
|
||||
))}
|
||||
<DropdownMenuSeparator />
|
||||
<SelectorItem
|
||||
className="flex cursor-pointer gap-2 smart-capitalize"
|
||||
onClick={() => setNewFace(true)}
|
||||
>
|
||||
{t("createFaceLibrary.new")}
|
||||
</SelectorItem>
|
||||
</div>
|
||||
</SelectorContent>
|
||||
</Selector>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -262,10 +262,7 @@ export default function MobileReviewSettingsDrawer({
|
||||
end_time: debugReplayRange.before,
|
||||
});
|
||||
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
setDebugReplayMode("none");
|
||||
setDebugReplayRange(undefined);
|
||||
setDrawerMode("none");
|
||||
@@ -391,10 +388,8 @@ export default function MobileReviewSettingsDrawer({
|
||||
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,
|
||||
after: latestTime - 60,
|
||||
before: latestTime,
|
||||
});
|
||||
setSelectedReplayOption("1");
|
||||
@@ -541,11 +536,9 @@ export default function MobileReviewSettingsDrawer({
|
||||
return;
|
||||
}
|
||||
|
||||
const hours = parseInt(option);
|
||||
const minutes = parseInt(option, 10);
|
||||
const end = latestTime;
|
||||
const now = new Date(end * 1000);
|
||||
now.setHours(now.getHours() - hours);
|
||||
setDebugReplayRange({ after: now.getTime() / 1000, before: end });
|
||||
setDebugReplayRange({ after: end - minutes * 60, before: end });
|
||||
};
|
||||
|
||||
content = (
|
||||
|
||||
@@ -178,6 +178,7 @@ export default function MultiExportDialog({
|
||||
start_time: review.start_time - REVIEW_PADDING,
|
||||
end_time: (review.end_time ?? Date.now() / 1000) + REVIEW_PADDING,
|
||||
image_path: review.thumb_path || undefined,
|
||||
friendly_name: review.data.metadata?.title?.trim() || undefined,
|
||||
client_item_id: review.id,
|
||||
}));
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import useKeyboardListener from "@/hooks/use-keyboard-listener";
|
||||
import { CameraPtzInfo } from "@/types/ptz";
|
||||
import React, { useCallback } from "react";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { BsThreeDotsVertical } from "react-icons/bs";
|
||||
import {
|
||||
FaAngleDown,
|
||||
@@ -292,7 +292,7 @@ export default function PtzControlPanel({
|
||||
</Tooltip>
|
||||
)}
|
||||
{(ptz?.presets?.length ?? 0) > 0 && (
|
||||
<DropdownMenu modal={!isDesktop}>
|
||||
<DropdownMenu>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<DropdownMenuTrigger asChild>
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { throttle } from "lodash";
|
||||
import { Slider } from "@/components/ui/slider";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "../../ui/popover";
|
||||
@@ -19,11 +21,21 @@ import { useIsAdmin } from "@/hooks/use-is-admin";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
const SLIDER_DRAG_THROTTLE_MS = 80;
|
||||
|
||||
type Props = {
|
||||
className?: string;
|
||||
// Optional side-effect invoked atomically with setAnnotationOffset (inside
|
||||
// flushSync) so callers like the timeline panel can re-seek the video in the
|
||||
// same React commit as the offset state update — preventing a one-frame
|
||||
// overlay mismatch where annotationOffset has changed but currentTime has not.
|
||||
onApplyOffset?: (newOffset: number) => void;
|
||||
};
|
||||
|
||||
export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
export default function AnnotationOffsetSlider({
|
||||
className,
|
||||
onApplyOffset,
|
||||
}: Props) {
|
||||
const { annotationOffset, setAnnotationOffset, camera } = useDetailStream();
|
||||
const isAdmin = useIsAdmin();
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
@@ -31,31 +43,62 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const applyOffset = useCallback(
|
||||
(newOffset: number) => {
|
||||
flushSync(() => {
|
||||
setAnnotationOffset(newOffset);
|
||||
onApplyOffset?.(newOffset);
|
||||
});
|
||||
},
|
||||
[setAnnotationOffset, onApplyOffset],
|
||||
);
|
||||
|
||||
const throttledApplyOffset = useMemo(
|
||||
() =>
|
||||
throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, {
|
||||
leading: true,
|
||||
trailing: true,
|
||||
}),
|
||||
[applyOffset],
|
||||
);
|
||||
|
||||
useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]);
|
||||
|
||||
const handleChange = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
const valueMs = values[0];
|
||||
setAnnotationOffset(valueMs);
|
||||
throttledApplyOffset(values[0]);
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
[throttledApplyOffset],
|
||||
);
|
||||
|
||||
const handleCommit = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
// Ensure the final value lands even if it would otherwise be discarded
|
||||
// by the trailing edge of the throttle window.
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(values[0]);
|
||||
},
|
||||
[throttledApplyOffset, applyOffset],
|
||||
);
|
||||
|
||||
const stepOffset = useCallback(
|
||||
(delta: number) => {
|
||||
setAnnotationOffset((prev) => {
|
||||
const next = prev + delta;
|
||||
return Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
||||
);
|
||||
});
|
||||
const next = Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
|
||||
);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(next);
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
[annotationOffset, applyOffset, throttledApplyOffset],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setAnnotationOffset(0);
|
||||
}, [setAnnotationOffset]);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(0);
|
||||
}, [applyOffset, throttledApplyOffset]);
|
||||
|
||||
const save = useCallback(async () => {
|
||||
setIsSaving(true);
|
||||
@@ -130,6 +173,7 @@ export default function AnnotationOffsetSlider({ className }: Props) {
|
||||
max={ANNOTATION_OFFSET_MAX}
|
||||
step={ANNOTATION_OFFSET_STEP}
|
||||
onValueChange={handleChange}
|
||||
onValueCommit={handleCommit}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { Event } from "@/types/event";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import axios from "axios";
|
||||
import { useCallback, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { throttle } from "lodash";
|
||||
import { LuExternalLink, LuMinus, LuPlus } from "react-icons/lu";
|
||||
import { Link } from "react-router-dom";
|
||||
import { toast } from "sonner";
|
||||
@@ -19,6 +21,8 @@ import {
|
||||
ANNOTATION_OFFSET_STEP,
|
||||
} from "@/lib/const";
|
||||
|
||||
const SLIDER_DRAG_THROTTLE_MS = 80;
|
||||
|
||||
type AnnotationSettingsPaneProps = {
|
||||
event: Event;
|
||||
annotationOffset: number;
|
||||
@@ -38,30 +42,64 @@ export function AnnotationSettingsPane({
|
||||
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
setAnnotationOffset(values[0]);
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
);
|
||||
|
||||
const stepOffset = useCallback(
|
||||
(delta: number) => {
|
||||
setAnnotationOffset((prev) => {
|
||||
const next = prev + delta;
|
||||
return Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, next),
|
||||
);
|
||||
// flushSync ensures setAnnotationOffset commits synchronously so the
|
||||
// useLayoutEffect in TrackingDetails (which seeks the video and sets
|
||||
// currentTime in response) runs before the browser paints — preventing a
|
||||
// one-frame overlay mismatch where annotationOffset has changed but
|
||||
// currentTime has not.
|
||||
const applyOffset = useCallback(
|
||||
(newOffset: number) => {
|
||||
flushSync(() => {
|
||||
setAnnotationOffset(newOffset);
|
||||
});
|
||||
},
|
||||
[setAnnotationOffset],
|
||||
);
|
||||
|
||||
const throttledApplyOffset = useMemo(
|
||||
() =>
|
||||
throttle(applyOffset, SLIDER_DRAG_THROTTLE_MS, {
|
||||
leading: true,
|
||||
trailing: true,
|
||||
}),
|
||||
[applyOffset],
|
||||
);
|
||||
|
||||
useEffect(() => () => throttledApplyOffset.cancel(), [throttledApplyOffset]);
|
||||
|
||||
const handleSliderChange = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
throttledApplyOffset(values[0]);
|
||||
},
|
||||
[throttledApplyOffset],
|
||||
);
|
||||
|
||||
const handleSliderCommit = useCallback(
|
||||
(values: number[]) => {
|
||||
if (!values || values.length === 0) return;
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(values[0]);
|
||||
},
|
||||
[throttledApplyOffset, applyOffset],
|
||||
);
|
||||
|
||||
const stepOffset = useCallback(
|
||||
(delta: number) => {
|
||||
const next = Math.max(
|
||||
ANNOTATION_OFFSET_MIN,
|
||||
Math.min(ANNOTATION_OFFSET_MAX, annotationOffset + delta),
|
||||
);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(next);
|
||||
},
|
||||
[annotationOffset, applyOffset, throttledApplyOffset],
|
||||
);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setAnnotationOffset(0);
|
||||
}, [setAnnotationOffset]);
|
||||
throttledApplyOffset.cancel();
|
||||
applyOffset(0);
|
||||
}, [applyOffset, throttledApplyOffset]);
|
||||
|
||||
const saveToConfig = useCallback(async () => {
|
||||
if (!config || !event) return;
|
||||
@@ -143,6 +181,7 @@ export function AnnotationSettingsPane({
|
||||
max={ANNOTATION_OFFSET_MAX}
|
||||
step={ANNOTATION_OFFSET_STEP}
|
||||
onValueChange={handleSliderChange}
|
||||
onValueCommit={handleSliderCommit}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
|
||||
@@ -73,7 +73,7 @@ export default function DetailActionsMenu({
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
|
||||
@@ -241,7 +241,7 @@ function AnnotationSettings({
|
||||
|
||||
return (
|
||||
<div className="ml-2">
|
||||
<Overlay modal={isDesktop} open={open} onOpenChange={handleOpenChange}>
|
||||
<Overlay open={open} onOpenChange={handleOpenChange}>
|
||||
<Trigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
@@ -269,7 +269,6 @@ function AnnotationSettings({
|
||||
: "mx-1 max-h-[75dvh] overflow-hidden rounded-t-2xl px-4 pb-4"
|
||||
}
|
||||
{...contentProps}
|
||||
{...(isDesktop ? { disablePortal: true } : {})}
|
||||
data-annotation-popover
|
||||
>
|
||||
<AnnotationSettingsPane
|
||||
@@ -958,8 +957,9 @@ function ObjectDetailsTab({
|
||||
toast.success(
|
||||
t("details.item.toast.success.regenerate", {
|
||||
provider: capitalizeAll(
|
||||
config?.genai.provider.replaceAll("_", " ") ??
|
||||
t("generativeAI"),
|
||||
Object.values(config?.genai ?? {})
|
||||
.find((agent) => agent?.roles?.includes("descriptions"))
|
||||
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
|
||||
),
|
||||
}),
|
||||
{
|
||||
@@ -977,8 +977,9 @@ function ObjectDetailsTab({
|
||||
toast.error(
|
||||
t("details.item.toast.error.regenerate", {
|
||||
provider: capitalizeAll(
|
||||
config?.genai.provider.replaceAll("_", " ") ??
|
||||
t("generativeAI"),
|
||||
Object.values(config?.genai ?? {})
|
||||
.find((agent) => agent?.roles?.includes("descriptions"))
|
||||
?.provider?.replaceAll("_", " ") ?? t("generativeAI"),
|
||||
),
|
||||
errorMessage,
|
||||
}),
|
||||
|
||||
@@ -1,5 +1,13 @@
|
||||
import useSWR from "swr";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { flushSync } from "react-dom";
|
||||
import { useResizeObserver } from "@/hooks/resize-observer";
|
||||
import { useFullscreen } from "@/hooks/use-fullscreen";
|
||||
import { Event } from "@/types/event";
|
||||
@@ -389,7 +397,12 @@ export function TrackingDetails({
|
||||
|
||||
// When the pinned timestamp or offset changes, re-seek the video and
|
||||
// explicitly update currentTime so the overlay shows the pinned event's box.
|
||||
useEffect(() => {
|
||||
// useLayoutEffect + flushSync force the setCurrentTime commit to land before
|
||||
// the browser paints, so the overlay never shows a frame where
|
||||
// annotationOffset has changed but currentTime has not — that mismatch would
|
||||
// resolve effectiveCurrentTime away from the pinned detect timestamp and
|
||||
// make the bounding box disappear or jump for one frame.
|
||||
useLayoutEffect(() => {
|
||||
const pinned = pinnedDetectTimestampRef.current;
|
||||
if (!isAnnotationSettingsOpen || pinned == null) return;
|
||||
if (!videoRef.current || displaySource !== "video") return;
|
||||
@@ -398,10 +411,9 @@ export function TrackingDetails({
|
||||
const relativeTime = timestampToVideoTime(targetTimeRecord);
|
||||
videoRef.current.currentTime = relativeTime;
|
||||
|
||||
// Explicitly update currentTime state so the overlay's effectiveCurrentTime
|
||||
// resolves back to the pinned detect timestamp:
|
||||
// effectiveCurrentTime = targetTimeRecord - annotationOffset/1000 = pinned
|
||||
setCurrentTime(targetTimeRecord);
|
||||
flushSync(() => {
|
||||
setCurrentTime(targetTimeRecord);
|
||||
});
|
||||
}, [
|
||||
isAnnotationSettingsOpen,
|
||||
annotationOffset,
|
||||
@@ -1204,7 +1216,11 @@ function LifecycleIconRow({
|
||||
<div className="flex flex-row items-center gap-3">
|
||||
<div className="whitespace-nowrap">{formattedEventTimestamp}</div>
|
||||
{isAdmin && (config?.plus?.enabled || item.data.box) && (
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={isOpen}
|
||||
onOpenChange={setIsOpen}
|
||||
>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded p-1 pr-2" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
|
||||
@@ -38,12 +38,6 @@ export default function RestartDialog({
|
||||
const [restartingSheetOpen, setRestartingSheetOpen] = useState(false);
|
||||
const [countdown, setCountdown] = useState(60);
|
||||
|
||||
const clearBodyPointerEvents = () => {
|
||||
if (typeof document !== "undefined") {
|
||||
document.body.style.pointerEvents = "";
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
setRestartDialogOpen(isOpen);
|
||||
}, [isOpen]);
|
||||
@@ -85,16 +79,10 @@ export default function RestartDialog({
|
||||
if (!open) {
|
||||
setRestartDialogOpen(false);
|
||||
onClose();
|
||||
clearBodyPointerEvents();
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent
|
||||
onCloseAutoFocus={(event) => {
|
||||
event.preventDefault();
|
||||
clearBodyPointerEvents();
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{t("restart.title")}</AlertDialogTitle>
|
||||
<AlertDialogDescription className="sr-only">
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import TextEntry from "@/components/input/TextEntry";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
@@ -19,7 +20,9 @@ type TextEntryDialogProps = {
|
||||
setOpen: (open: boolean) => void;
|
||||
onSave: (text: string) => void;
|
||||
defaultValue?: string;
|
||||
placeholder?: string;
|
||||
allowEmpty?: boolean;
|
||||
isSaving?: boolean;
|
||||
regexPattern?: RegExp;
|
||||
regexErrorMessage?: string;
|
||||
forbiddenPattern?: RegExp;
|
||||
@@ -33,7 +36,9 @@ export default function TextEntryDialog({
|
||||
setOpen,
|
||||
onSave,
|
||||
defaultValue = "",
|
||||
placeholder,
|
||||
allowEmpty = false,
|
||||
isSaving = false,
|
||||
regexPattern,
|
||||
regexErrorMessage,
|
||||
forbiddenPattern,
|
||||
@@ -50,6 +55,7 @@ export default function TextEntryDialog({
|
||||
</DialogHeader>
|
||||
<TextEntry
|
||||
defaultValue={defaultValue}
|
||||
placeholder={placeholder}
|
||||
allowEmpty={allowEmpty}
|
||||
onSave={onSave}
|
||||
regexPattern={regexPattern}
|
||||
@@ -58,11 +64,22 @@ export default function TextEntryDialog({
|
||||
forbiddenErrorMessage={forbiddenErrorMessage}
|
||||
>
|
||||
<DialogFooter className={cn("pt-4", isMobile && "gap-2")}>
|
||||
<Button type="button" onClick={() => setOpen(false)}>
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("button.cancel")}
|
||||
</Button>
|
||||
<Button variant="select" type="submit">
|
||||
{t("button.save")}
|
||||
<Button variant="select" type="submit" disabled={isSaving}>
|
||||
{isSaving ? (
|
||||
<div className="flex flex-row items-center gap-2">
|
||||
<ActivityIndicator className="size-4" />
|
||||
<span>{t("button.saving")}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save")
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</TextEntry>
|
||||
|
||||
@@ -396,7 +396,6 @@ export default function HlsVideoPlayer({
|
||||
}}
|
||||
>
|
||||
<ObjectTrackOverlay
|
||||
key={`overlay-${currentTime}`}
|
||||
camera={camera}
|
||||
showBoundingBoxes={!isPlaying}
|
||||
currentTime={currentTime}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useMemo, useRef, useState } from "react";
|
||||
import { LuFolderX } from "react-icons/lu";
|
||||
import { isDesktop, isMobileOnly, isSafari } from "react-device-detect";
|
||||
import { isMobileOnly, isSafari } from "react-device-detect";
|
||||
import { LuPause, LuPlay } from "react-icons/lu";
|
||||
import {
|
||||
DropdownMenu,
|
||||
@@ -198,7 +198,7 @@ export default function VideoControls({
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"z-50 flex w-auto items-center justify-between gap-4 rounded-lg bg-background/60 px-4 py-2 text-primary sm:flex-nowrap sm:gap-8",
|
||||
"z-50 flex w-auto select-none items-center justify-between gap-4 rounded-lg bg-background/60 px-4 py-2 text-primary sm:flex-nowrap sm:gap-8",
|
||||
className,
|
||||
isMobileOnly &&
|
||||
Object.values(features).filter((feat) => feat).length >
|
||||
@@ -246,7 +246,6 @@ export default function VideoControls({
|
||||
)}
|
||||
{features.playbackRate && (
|
||||
<DropdownMenu
|
||||
modal={!isDesktop}
|
||||
onOpenChange={(open) => {
|
||||
if (setControlsOpen) {
|
||||
setControlsOpen(open);
|
||||
|
||||
@@ -19,7 +19,7 @@ import { LuCopy, LuPencil } from "react-icons/lu";
|
||||
import { FaDrawPolygon, FaObjectGroup } from "react-icons/fa";
|
||||
import { BsPersonBoundingBox } from "react-icons/bs";
|
||||
import { HiOutlineDotsVertical, HiTrash } from "react-icons/hi";
|
||||
import { isDesktop, isMobile } from "react-device-detect";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { toRGBColorString } from "@/utils/canvasUtil";
|
||||
import { Polygon, PolygonType } from "@/types/canvas";
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
@@ -524,7 +524,7 @@ export default function PolygonItem({
|
||||
|
||||
{isMobile && (
|
||||
<>
|
||||
<DropdownMenu modal={!isDesktop}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger>
|
||||
<HiOutlineDotsVertical className="size-5" />
|
||||
</DropdownMenuTrigger>
|
||||
|
||||
@@ -41,7 +41,7 @@ export function ProfileSectionDropdown({
|
||||
: null;
|
||||
|
||||
return (
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
{iconOnly ? (
|
||||
<Button variant="outline" size="sm">
|
||||
@@ -91,7 +91,7 @@ export function ProfileSectionDropdown({
|
||||
className="group flex items-start justify-between gap-2"
|
||||
onClick={() => onSelectProfile(profile)}
|
||||
>
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex flex-col items-start gap-2">
|
||||
<div className="flex w-full flex-row items-center justify-start gap-2">
|
||||
{isActive && <Check className="h-3.5 w-3.5 shrink-0" />}
|
||||
<span
|
||||
|
||||
@@ -528,7 +528,7 @@ export default function ZoneEditPane({
|
||||
);
|
||||
updateConfig();
|
||||
// Only publish WS state for base config when zone has a name
|
||||
if (!editingProfile && zoneName) {
|
||||
if (!editingProfile && polygon?.name) {
|
||||
sendZoneState(enabled ? "ON" : "OFF");
|
||||
}
|
||||
} else {
|
||||
|
||||
@@ -377,7 +377,7 @@ export default function Step1NameCamera({
|
||||
);
|
||||
return selectedBrand &&
|
||||
selectedBrand.value != "other" ? (
|
||||
<Popover modal={true}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
|
||||
@@ -415,10 +415,7 @@ export default function Step3StreamConfig({
|
||||
</Button>
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className="w-[--radix-popover-trigger-width] p-2"
|
||||
disablePortal
|
||||
>
|
||||
<PopoverContent className="w-[--radix-popover-trigger-width] p-2">
|
||||
<Command>
|
||||
<CommandInput
|
||||
placeholder={t(
|
||||
@@ -600,7 +597,7 @@ export default function Step3StreamConfig({
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.roles")}
|
||||
</Label>
|
||||
<Popover modal={true}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
@@ -670,7 +667,7 @@ export default function Step3StreamConfig({
|
||||
<Label className="text-sm font-medium text-primary-variant">
|
||||
{t("cameraWizard.step3.featuresTitle")}
|
||||
</Label>
|
||||
<Popover modal={true}>
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-4 w-4 p-0">
|
||||
<LuInfo className="size-3" />
|
||||
|
||||
@@ -607,23 +607,38 @@ function StreamIssues({
|
||||
}
|
||||
}
|
||||
|
||||
if (stream.roles.includes("detect") && stream.resolution) {
|
||||
const [width, height] = stream.resolution.split("x").map(Number);
|
||||
if (!isNaN(width) && !isNaN(height) && width > 0 && height > 0) {
|
||||
const minDimension = Math.min(width, height);
|
||||
const maxDimension = Math.max(width, height);
|
||||
if (stream.roles.includes("detect") && stream.testResult) {
|
||||
const probedResolution = stream.testResult.resolution;
|
||||
let probedWidth = 0;
|
||||
let probedHeight = 0;
|
||||
if (probedResolution) {
|
||||
const [w, h] = probedResolution.split("x").map(Number);
|
||||
if (!isNaN(w) && !isNaN(h)) {
|
||||
probedWidth = w;
|
||||
probedHeight = h;
|
||||
}
|
||||
}
|
||||
|
||||
if (probedWidth <= 0 || probedHeight <= 0) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step4.issues.resolutionUnknown"),
|
||||
});
|
||||
} else {
|
||||
const minDimension = Math.min(probedWidth, probedHeight);
|
||||
const maxDimension = Math.max(probedWidth, probedHeight);
|
||||
if (minDimension > 1080) {
|
||||
result.push({
|
||||
type: "warning",
|
||||
message: t("cameraWizard.step4.issues.resolutionHigh", {
|
||||
resolution: stream.resolution,
|
||||
resolution: probedResolution,
|
||||
}),
|
||||
});
|
||||
} else if (maxDimension < 640) {
|
||||
result.push({
|
||||
type: "error",
|
||||
message: t("cameraWizard.step4.issues.resolutionLow", {
|
||||
resolution: stream.resolution,
|
||||
resolution: probedResolution,
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -126,13 +126,20 @@ export default function DetailStream({
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [controlsExpanded]);
|
||||
|
||||
// Re-seek on annotation offset change while settings panel is open
|
||||
useEffect(() => {
|
||||
const pinned = pinnedDetectTimestampRef.current;
|
||||
if (!controlsExpanded || pinned == null) return;
|
||||
const recordTime = pinned + annotationOffset / 1000;
|
||||
onSeek(recordTime, false);
|
||||
}, [controlsExpanded, annotationOffset, onSeek]);
|
||||
// The slider invokes this atomically with setAnnotationOffset (inside the
|
||||
// same flushSync) so currentTime advances in the same React commit as the
|
||||
// offset. Without this, the overlay would render one frame with the new
|
||||
// offset but the old currentTime, briefly resolving effectiveCurrentTime to
|
||||
// the wrong detect-stream timestamp and making the bounding box vanish or
|
||||
// jump.
|
||||
const handleApplyOffset = useCallback(
|
||||
(newOffset: number) => {
|
||||
const pinned = pinnedDetectTimestampRef.current;
|
||||
if (!controlsExpanded || pinned == null) return;
|
||||
onSeek(pinned + newOffset / 1000, false);
|
||||
},
|
||||
[controlsExpanded, onSeek],
|
||||
);
|
||||
|
||||
// Ensure we initialize the active review when reviewItems first arrive.
|
||||
// This helps when the component mounts while the video is already
|
||||
@@ -337,7 +344,7 @@ export default function DetailStream({
|
||||
</button>
|
||||
{controlsExpanded && (
|
||||
<div className="space-y-4 px-3 pb-5 pt-2">
|
||||
<AnnotationOffsetSlider />
|
||||
<AnnotationOffsetSlider onApplyOffset={handleApplyOffset} />
|
||||
<Separator />
|
||||
<div className="flex flex-col gap-1">
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -61,10 +61,7 @@ export default function EventMenu({
|
||||
end_time: event.end_time,
|
||||
})
|
||||
.then((response) => {
|
||||
if (response.status === 200) {
|
||||
toast.success(t("dialog.toast.success", { ns: "views/replay" }), {
|
||||
position: "top-center",
|
||||
});
|
||||
if (response.status === 202 || response.status === 200) {
|
||||
navigate("/replay");
|
||||
}
|
||||
})
|
||||
@@ -106,7 +103,7 @@ export default function EventMenu({
|
||||
return (
|
||||
<>
|
||||
<span tabIndex={0} className="sr-only" />
|
||||
<DropdownMenu open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenu modal={false} open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuTrigger>
|
||||
<div className="rounded p-1 pr-2" role="button">
|
||||
<HiDotsHorizontal className="size-4 text-muted-foreground" />
|
||||
|
||||
@@ -539,7 +539,7 @@ export function ReviewTimeline({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>{t("zoomIn")}</TooltipContent>
|
||||
<TooltipContent>{t("zoomOut")}</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
<Tooltip>
|
||||
@@ -562,7 +562,7 @@ export function ReviewTimeline({
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipPortal>
|
||||
<TooltipContent>{t("zoomOut")}</TooltipContent>
|
||||
<TooltipContent>{t("zoomIn")}</TooltipContent>
|
||||
</TooltipPortal>
|
||||
</Tooltip>
|
||||
</div>
|
||||
|
||||
@@ -351,7 +351,6 @@ function TopicFilterButton({
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCurrentTopics(selectedTopics);
|
||||
@@ -517,7 +516,6 @@ function WsCamerasFilterButton({
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setCurrentCameras(selectedCameras);
|
||||
|
||||
@@ -1,12 +1,22 @@
|
||||
// Hook to detect when camera config overrides global defaults
|
||||
import { useMemo } from "react";
|
||||
import useSWR from "swr";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import get from "lodash/get";
|
||||
import set from "lodash/set";
|
||||
import type { RJSFSchema } from "@rjsf/utils";
|
||||
import { FrigateConfig } from "@/types/frigateConfig";
|
||||
import { JsonObject, JsonValue } from "@/types/configForm";
|
||||
import { isJsonObject } from "@/lib/utils";
|
||||
import { getBaseCameraSectionValue } from "@/utils/configUtil";
|
||||
import {
|
||||
getBaseCameraSectionValue,
|
||||
getEffectiveHiddenFields,
|
||||
pathMatchesHiddenPattern,
|
||||
unsetWithWildcard,
|
||||
} from "@/utils/configUtil";
|
||||
import { extractSectionSchema } from "@/hooks/use-config-schema";
|
||||
import { applySchemaDefaults } from "@/lib/config-schema";
|
||||
|
||||
const INTERNAL_FIELD_SUFFIXES = ["enabled_in_config", "raw_mask"];
|
||||
|
||||
@@ -34,6 +44,51 @@ export function normalizeConfigValue(value: unknown): JsonValue {
|
||||
return stripInternalFields(value as JsonValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove hidden-field paths from a value before comparison so fields the
|
||||
* user can't change in the UI (e.g. motion masks, attribute filters) don't
|
||||
* trigger override badges. Operates on a clone so the input is unchanged.
|
||||
*/
|
||||
function stripHiddenPaths(value: JsonValue, hiddenFields: string[]): JsonValue {
|
||||
if (hiddenFields.length === 0 || !isJsonObject(value)) return value;
|
||||
const cloned = cloneDeep(value) as JsonObject;
|
||||
for (const path of hiddenFields) {
|
||||
if (!path) continue;
|
||||
unsetWithWildcard(cloned as Record<string, unknown>, path);
|
||||
}
|
||||
return cloned;
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse null and empty-object values for override comparisons so
|
||||
* semantically equivalent shapes match. The schema may default `mask: None`
|
||||
* while the runtime camera config carries `mask: {}` — both mean "no
|
||||
* masks", so collapsing them here keeps the equality check honest. We
|
||||
* keep this off the public `normalizeConfigValue` so save-flow code paths
|
||||
* (which serialize form data) aren't affected.
|
||||
**/
|
||||
function collapseEmpty(value: JsonValue): JsonValue {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(collapseEmpty);
|
||||
}
|
||||
if (isJsonObject(value)) {
|
||||
const cleaned: JsonObject = {};
|
||||
for (const [key, val] of Object.entries(value as JsonObject)) {
|
||||
if (val === null || val === undefined) continue;
|
||||
const collapsed = collapseEmpty(val as JsonValue);
|
||||
if (
|
||||
isJsonObject(collapsed) &&
|
||||
Object.keys(collapsed as JsonObject).length === 0
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
cleaned[key] = collapsed;
|
||||
}
|
||||
return cleaned;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export interface OverrideStatus {
|
||||
/** Whether the field is overridden from global */
|
||||
isOverridden: boolean;
|
||||
@@ -96,6 +151,7 @@ export function useConfigOverride({
|
||||
sectionPath,
|
||||
compareFields,
|
||||
}: UseConfigOverrideOptions) {
|
||||
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
|
||||
return useMemo(() => {
|
||||
if (!config) {
|
||||
return {
|
||||
@@ -153,15 +209,42 @@ export function useConfigOverride({
|
||||
sectionPath,
|
||||
);
|
||||
|
||||
const normalizedGlobalValue = normalizeConfigValue(globalValue);
|
||||
// Use the effective baseline (schema defaults when the global section
|
||||
// is unset, e.g. motion). Without this, sections omitted from the global
|
||||
// YAML would always read as "overridden" because the raw global value is
|
||||
// null while every camera has populated defaults.
|
||||
const normalizedGlobalValue = getEffectiveGlobalBaseline(
|
||||
config,
|
||||
sectionPath,
|
||||
compareFields,
|
||||
schema,
|
||||
);
|
||||
const normalizedCameraValue = normalizeConfigValue(cameraValue);
|
||||
|
||||
// Collapse empty/null values for comparison so semantically equivalent
|
||||
// shapes (e.g. schema default `mask: null` vs runtime `mask: {}`) match.
|
||||
// Also strip hidden-field paths (motion masks, attribute filters, etc.)
|
||||
// so fields the user can't edit in the UI don't trigger override badges.
|
||||
const hiddenFields = getEffectiveHiddenFields(
|
||||
sectionPath,
|
||||
"camera",
|
||||
config,
|
||||
);
|
||||
const collapsedGlobal = stripHiddenPaths(
|
||||
collapseEmpty(normalizedGlobalValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const collapsedCamera = stripHiddenPaths(
|
||||
collapseEmpty(normalizedCameraValue),
|
||||
hiddenFields,
|
||||
);
|
||||
|
||||
const comparisonGlobal = compareFields
|
||||
? pickFields(normalizedGlobalValue, compareFields)
|
||||
: normalizedGlobalValue;
|
||||
? pickFields(collapsedGlobal, compareFields)
|
||||
: collapsedGlobal;
|
||||
const comparisonCamera = compareFields
|
||||
? pickFields(normalizedCameraValue, compareFields)
|
||||
: normalizedCameraValue;
|
||||
? pickFields(collapsedCamera, compareFields)
|
||||
: collapsedCamera;
|
||||
|
||||
// Check if the entire section is overridden
|
||||
const isOverridden = compareFields
|
||||
@@ -176,7 +259,10 @@ export function useConfigOverride({
|
||||
const cameraFieldValue = get(normalizedCameraValue, fieldPath);
|
||||
|
||||
return {
|
||||
isOverridden: !isEqual(globalFieldValue, cameraFieldValue),
|
||||
isOverridden: !isEqual(
|
||||
collapseEmpty(globalFieldValue as JsonValue),
|
||||
collapseEmpty(cameraFieldValue as JsonValue),
|
||||
),
|
||||
globalValue: globalFieldValue,
|
||||
cameraValue: cameraFieldValue,
|
||||
};
|
||||
@@ -199,9 +285,52 @@ export function useConfigOverride({
|
||||
getFieldOverride,
|
||||
resetToGlobal,
|
||||
};
|
||||
}, [config, cameraName, sectionPath, compareFields]);
|
||||
}, [config, cameraName, sectionPath, compareFields, schema]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Sections that can be overridden per-camera, with optional compareFields
|
||||
* filters that scope the override comparison to a subset of fields.
|
||||
*/
|
||||
export const OVERRIDABLE_SECTIONS: ReadonlyArray<{
|
||||
key: string;
|
||||
compareFields?: string[];
|
||||
}> = [
|
||||
{ key: "detect" },
|
||||
{ key: "record" },
|
||||
{ key: "snapshots" },
|
||||
{ key: "motion" },
|
||||
{ key: "objects" },
|
||||
{ key: "review" },
|
||||
{ key: "audio" },
|
||||
{ key: "notifications" },
|
||||
{ key: "live" },
|
||||
{ key: "timestamp_style" },
|
||||
{
|
||||
key: "audio_transcription",
|
||||
compareFields: ["enabled", "live_enabled"],
|
||||
},
|
||||
{ key: "birdseye", compareFields: ["enabled", "mode"] },
|
||||
{ key: "face_recognition", compareFields: ["enabled", "min_area"] },
|
||||
{
|
||||
key: "ffmpeg",
|
||||
compareFields: [
|
||||
"path",
|
||||
"global_args",
|
||||
"hwaccel_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "lpr",
|
||||
compareFields: ["enabled", "min_area", "enhancement"],
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Hook to get all overridden fields for a camera
|
||||
*/
|
||||
@@ -209,6 +338,7 @@ export function useAllCameraOverrides(
|
||||
config: FrigateConfig | undefined,
|
||||
cameraName: string | undefined,
|
||||
) {
|
||||
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
|
||||
return useMemo(() => {
|
||||
if (!config || !cameraName) {
|
||||
return [];
|
||||
@@ -221,58 +351,32 @@ export function useAllCameraOverrides(
|
||||
|
||||
const overriddenSections: string[] = [];
|
||||
|
||||
// Check each section that can be overridden
|
||||
const sectionsToCheck: Array<{
|
||||
key: string;
|
||||
compareFields?: string[];
|
||||
}> = [
|
||||
{ key: "detect" },
|
||||
{ key: "record" },
|
||||
{ key: "snapshots" },
|
||||
{ key: "motion" },
|
||||
{ key: "objects" },
|
||||
{ key: "review" },
|
||||
{ key: "audio" },
|
||||
{ key: "notifications" },
|
||||
{ key: "live" },
|
||||
{ key: "timestamp_style" },
|
||||
{
|
||||
key: "audio_transcription",
|
||||
compareFields: ["enabled", "live_enabled"],
|
||||
},
|
||||
{ key: "birdseye", compareFields: ["enabled", "mode"] },
|
||||
{ key: "face_recognition", compareFields: ["enabled", "min_area"] },
|
||||
{
|
||||
key: "ffmpeg",
|
||||
compareFields: [
|
||||
"path",
|
||||
"global_args",
|
||||
"hwaccel_args",
|
||||
"input_args",
|
||||
"output_args",
|
||||
"retry_interval",
|
||||
"apple_compatibility",
|
||||
"gpu",
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "lpr",
|
||||
compareFields: ["enabled", "min_area", "enhancement"],
|
||||
},
|
||||
];
|
||||
|
||||
for (const { key, compareFields } of sectionsToCheck) {
|
||||
const globalValue = normalizeConfigValue(get(config, key));
|
||||
for (const { key, compareFields } of OVERRIDABLE_SECTIONS) {
|
||||
const globalValue = getEffectiveGlobalBaseline(
|
||||
config,
|
||||
key,
|
||||
compareFields,
|
||||
schema,
|
||||
);
|
||||
const cameraValue = normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, cameraName, key),
|
||||
);
|
||||
|
||||
const hiddenFields = getEffectiveHiddenFields(key, "camera", config);
|
||||
const collapsedGlobal = stripHiddenPaths(
|
||||
collapseEmpty(globalValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const collapsedCamera = stripHiddenPaths(
|
||||
collapseEmpty(cameraValue),
|
||||
hiddenFields,
|
||||
);
|
||||
const comparisonGlobal = compareFields
|
||||
? pickFields(globalValue, compareFields)
|
||||
: globalValue;
|
||||
? pickFields(collapsedGlobal, compareFields)
|
||||
: collapsedGlobal;
|
||||
const comparisonCamera = compareFields
|
||||
? pickFields(cameraValue, compareFields)
|
||||
: cameraValue;
|
||||
? pickFields(collapsedCamera, compareFields)
|
||||
: collapsedCamera;
|
||||
|
||||
if (
|
||||
compareFields && compareFields.length === 0
|
||||
@@ -284,5 +388,414 @@ export function useAllCameraOverrides(
|
||||
}
|
||||
|
||||
return overriddenSections;
|
||||
}, [config, cameraName]);
|
||||
}, [config, cameraName, schema]);
|
||||
}
|
||||
|
||||
export interface FieldDelta {
|
||||
/** Path relative to the section (e.g. "genai.enabled") */
|
||||
fieldPath: string;
|
||||
globalValue: unknown;
|
||||
cameraValue: unknown;
|
||||
/** Profile name when the override originates from a profile; undefined for camera-level overrides */
|
||||
profileName?: string;
|
||||
}
|
||||
|
||||
export interface CameraOverrideEntry {
|
||||
camera: string;
|
||||
fieldDeltas: FieldDelta[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Collect leaf-level field differences between a global section value
|
||||
* and a camera section value. When compareFields is provided, only those
|
||||
* paths are compared; otherwise the objects are walked recursively.
|
||||
*/
|
||||
function collectFieldDeltas(
|
||||
globalValue: JsonValue,
|
||||
cameraValue: JsonValue,
|
||||
compareFields?: string[],
|
||||
pathPrefix = "",
|
||||
): FieldDelta[] {
|
||||
if (compareFields) {
|
||||
if (compareFields.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const deltas: FieldDelta[] = [];
|
||||
for (const path of compareFields) {
|
||||
const g = get(globalValue, path);
|
||||
const c = get(cameraValue, path);
|
||||
if (!isEqual(g, c)) {
|
||||
deltas.push({ fieldPath: path, globalValue: g, cameraValue: c });
|
||||
}
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
if (isJsonObject(globalValue) && isJsonObject(cameraValue)) {
|
||||
const deltas: FieldDelta[] = [];
|
||||
const keys = new Set([
|
||||
...Object.keys(globalValue),
|
||||
...Object.keys(cameraValue),
|
||||
]);
|
||||
for (const key of keys) {
|
||||
const g = (globalValue as JsonObject)[key];
|
||||
const c = (cameraValue as JsonObject)[key];
|
||||
if (isEqual(g, c)) continue;
|
||||
const childPath = pathPrefix ? `${pathPrefix}.${key}` : key;
|
||||
if (isJsonObject(g) && isJsonObject(c)) {
|
||||
deltas.push(...collectFieldDeltas(g, c, undefined, childPath));
|
||||
} else {
|
||||
deltas.push({ fieldPath: childPath, globalValue: g, cameraValue: c });
|
||||
}
|
||||
}
|
||||
return deltas;
|
||||
}
|
||||
|
||||
if (!isEqual(globalValue, cameraValue)) {
|
||||
return [{ fieldPath: pathPrefix, globalValue, cameraValue }];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Walk a partial config object and return the dot-paths of every leaf value
|
||||
* (primitive or array) actually defined on it. Used to limit profile-vs-global
|
||||
* diffs to keys the profile actually sets, avoiding false "undefined" deltas
|
||||
* for fields the profile leaves unspecified.
|
||||
*/
|
||||
function collectDefinedLeafPaths(value: JsonValue, prefix = ""): string[] {
|
||||
if (!isJsonObject(value)) {
|
||||
return prefix ? [prefix] : [];
|
||||
}
|
||||
const paths: string[] = [];
|
||||
for (const [key, val] of Object.entries(value as JsonObject)) {
|
||||
const childPath = prefix ? `${prefix}.${key}` : key;
|
||||
if (isJsonObject(val)) {
|
||||
paths.push(...collectDefinedLeafPaths(val as JsonValue, childPath));
|
||||
} else {
|
||||
paths.push(childPath);
|
||||
}
|
||||
}
|
||||
return paths;
|
||||
}
|
||||
|
||||
function isPathAllowed(path: string, compareFields?: string[]): boolean {
|
||||
if (!compareFields) return true;
|
||||
return compareFields.some(
|
||||
(allowed) => path === allowed || path.startsWith(`${allowed}.`),
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective global baseline used for override comparisons.
|
||||
*
|
||||
* - When the global section is explicitly set, return it (normalized).
|
||||
* - Otherwise prefer the camera-level schema defaults so a camera that
|
||||
* diverges from the implicit Pydantic default registers as overriding
|
||||
* even with a single camera in the deployment. (Sections like `motion`
|
||||
* are dumped with `exclude_unset=True`, so the API returns null whenever
|
||||
* the user hasn't written the section globally.)
|
||||
* - Fall back to a modal-across-cameras synthetic baseline when the schema
|
||||
* hasn't loaded yet or the section isn't in it.
|
||||
*/
|
||||
function getEffectiveGlobalBaseline(
|
||||
config: FrigateConfig,
|
||||
sectionPath: string,
|
||||
compareFields?: string[],
|
||||
schema?: RJSFSchema,
|
||||
): JsonValue {
|
||||
const rawGlobalValue = get(config, sectionPath);
|
||||
if (rawGlobalValue != null) {
|
||||
return normalizeConfigValue(rawGlobalValue);
|
||||
}
|
||||
if (schema) {
|
||||
const sectionSchema = extractSectionSchema(schema, sectionPath, "camera");
|
||||
if (sectionSchema) {
|
||||
const defaults = applySchemaDefaults(sectionSchema, {});
|
||||
return normalizeConfigValue(defaults as JsonValue);
|
||||
}
|
||||
}
|
||||
const cameraSectionValues = Object.keys(config.cameras ?? {}).map((name) =>
|
||||
normalizeConfigValue(getBaseCameraSectionValue(config, name, sectionPath)),
|
||||
);
|
||||
return deriveSyntheticGlobalValue(cameraSectionValues, compareFields);
|
||||
}
|
||||
|
||||
function deriveSyntheticGlobalValue(
|
||||
cameraSectionValues: JsonValue[],
|
||||
compareFields?: string[],
|
||||
): JsonObject {
|
||||
const cameras = cameraSectionValues.filter(isJsonObject) as JsonObject[];
|
||||
if (cameras.length === 0) return {};
|
||||
|
||||
const allPaths = new Set<string>();
|
||||
for (const cam of cameras) {
|
||||
for (const path of collectDefinedLeafPaths(cam as JsonValue)) {
|
||||
if (!isPathAllowed(path, compareFields)) continue;
|
||||
allPaths.add(path);
|
||||
}
|
||||
}
|
||||
|
||||
const baseline: JsonObject = {};
|
||||
for (const path of allPaths) {
|
||||
const counts = new Map<string, { value: unknown; count: number }>();
|
||||
for (const cam of cameras) {
|
||||
const v = get(cam, path);
|
||||
const key = JSON.stringify(v ?? null);
|
||||
const existing = counts.get(key);
|
||||
if (existing) {
|
||||
existing.count += 1;
|
||||
} else {
|
||||
counts.set(key, { value: v, count: 1 });
|
||||
}
|
||||
}
|
||||
let modal: { value: unknown; count: number } | undefined;
|
||||
for (const entry of counts.values()) {
|
||||
if (!modal || entry.count > modal.count) modal = entry;
|
||||
}
|
||||
if (modal) {
|
||||
set(baseline, path, modal.value);
|
||||
}
|
||||
}
|
||||
return baseline;
|
||||
}
|
||||
|
||||
/**
|
||||
* Paths that are intentionally hidden from the cross-camera override summary
|
||||
* because they're inherently per-camera (mask polygons, zone definitions) and
|
||||
* would otherwise dominate the popover with noise. Excludes any path where
|
||||
* `mask` appears as a path segment, so nested keys under a mask dict (e.g.
|
||||
* `mask.global_object_mask_1.coordinates`) are also filtered.
|
||||
*/
|
||||
function isCrossCameraIgnoredPath(path: string): boolean {
|
||||
if (!path) return false;
|
||||
return path.split(".").includes("mask");
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook to find every camera that overrides a given global section. Returns
|
||||
* one entry per overriding camera with the specific field-level deltas.
|
||||
* Considers both the camera's own (pre-profile) section value and any of its
|
||||
* defined profiles, so a field overridden only inside a profile still surfaces.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const entries = useCamerasOverridingSection(config, "review");
|
||||
* // [{ camera: "front_door", fieldDeltas: [{ fieldPath: "genai.enabled", ... }] }]
|
||||
* ```
|
||||
*/
|
||||
export function useCamerasOverridingSection(
|
||||
config: FrigateConfig | undefined,
|
||||
sectionPath: string,
|
||||
): CameraOverrideEntry[] {
|
||||
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
|
||||
return useMemo(() => {
|
||||
if (!config?.cameras || !sectionPath) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath);
|
||||
const compareFields = sectionMeta?.compareFields;
|
||||
|
||||
const cameraNames = Object.keys(config.cameras);
|
||||
const cameraSectionValues = cameraNames.map((name) =>
|
||||
normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, name, sectionPath),
|
||||
),
|
||||
);
|
||||
|
||||
const globalValue = collapseEmpty(
|
||||
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
|
||||
);
|
||||
|
||||
const entries: CameraOverrideEntry[] = [];
|
||||
for (let idx = 0; idx < cameraNames.length; idx += 1) {
|
||||
const cameraName = cameraNames[idx];
|
||||
const cameraConfig = config.cameras[cameraName];
|
||||
const deltasByPath = new Map<string, FieldDelta>();
|
||||
|
||||
// 1. Camera-level overrides (uses base_config when a profile is active)
|
||||
const cameraValue = collapseEmpty(cameraSectionValues[idx]);
|
||||
for (const delta of collectFieldDeltas(
|
||||
globalValue,
|
||||
cameraValue,
|
||||
compareFields,
|
||||
)) {
|
||||
if (isCrossCameraIgnoredPath(delta.fieldPath)) continue;
|
||||
deltasByPath.set(delta.fieldPath, delta);
|
||||
}
|
||||
|
||||
// 2. Profile-level overrides — diff only the paths each profile actually
|
||||
// defines, so unspecified-in-profile fields don't register as deltas.
|
||||
const profiles = cameraConfig?.profiles ?? {};
|
||||
for (const profileName of Object.keys(profiles)) {
|
||||
const profileSection = (
|
||||
profiles[profileName] as Record<string, unknown> | undefined
|
||||
)?.[sectionPath];
|
||||
if (profileSection === undefined) continue;
|
||||
const normalizedProfile = normalizeConfigValue(
|
||||
profileSection as JsonValue,
|
||||
);
|
||||
for (const path of collectDefinedLeafPaths(normalizedProfile)) {
|
||||
if (deltasByPath.has(path)) continue;
|
||||
if (isCrossCameraIgnoredPath(path)) continue;
|
||||
if (!isPathAllowed(path, compareFields)) continue;
|
||||
const g = get(globalValue, path);
|
||||
const p = get(normalizedProfile, path);
|
||||
if (!isEqual(g, p)) {
|
||||
deltasByPath.set(path, {
|
||||
fieldPath: path,
|
||||
globalValue: g,
|
||||
cameraValue: p,
|
||||
profileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (deltasByPath.size > 0) {
|
||||
entries.push({
|
||||
camera: cameraName,
|
||||
fieldDeltas: Array.from(deltasByPath.values()),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return entries;
|
||||
}, [config, sectionPath, schema]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook returning the field-level deltas between a single camera's base
|
||||
* (pre-profile) section value and the effective global baseline. Mirrors
|
||||
* `useConfigOverride`'s comparison logic but exposes per-field deltas so a
|
||||
* popover can list the overridden fields.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const deltas = useCameraSectionDeltas(config, "front_door", "detect");
|
||||
* // [{ fieldPath: "fps", globalValue: 5, cameraValue: 10 }]
|
||||
* ```
|
||||
*/
|
||||
export function useCameraSectionDeltas(
|
||||
config: FrigateConfig | undefined,
|
||||
cameraName: string | undefined,
|
||||
sectionPath: string,
|
||||
): FieldDelta[] {
|
||||
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
|
||||
return useMemo(() => {
|
||||
if (!config?.cameras || !cameraName || !sectionPath) {
|
||||
return [];
|
||||
}
|
||||
const cameraConfig = config.cameras[cameraName];
|
||||
if (!cameraConfig) return [];
|
||||
|
||||
const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath);
|
||||
const compareFields = sectionMeta?.compareFields;
|
||||
|
||||
const globalValue = collapseEmpty(
|
||||
getEffectiveGlobalBaseline(config, sectionPath, compareFields, schema),
|
||||
);
|
||||
const cameraValue = collapseEmpty(
|
||||
normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, cameraName, sectionPath),
|
||||
),
|
||||
);
|
||||
|
||||
const hiddenFields = getEffectiveHiddenFields(
|
||||
sectionPath,
|
||||
"camera",
|
||||
config,
|
||||
);
|
||||
|
||||
const deltas: FieldDelta[] = [];
|
||||
for (const delta of collectFieldDeltas(
|
||||
globalValue,
|
||||
cameraValue,
|
||||
compareFields,
|
||||
)) {
|
||||
if (
|
||||
hiddenFields.some((pattern) =>
|
||||
pathMatchesHiddenPattern(delta.fieldPath, pattern),
|
||||
)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
deltas.push(delta);
|
||||
}
|
||||
return deltas;
|
||||
}, [config, cameraName, sectionPath, schema]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook returning the field-level deltas between a single profile's overrides
|
||||
* and the camera's base (pre-profile) section value. Honors per-section
|
||||
* `compareFields` filters and hidden-field patterns so the result matches
|
||||
* what's actually exposed in the UI.
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const deltas = useProfileSectionDeltas(config, "front_door", "night", "detect");
|
||||
* // [{ fieldPath: "fps", globalValue: 5, cameraValue: 10, profileName: "night" }]
|
||||
* ```
|
||||
*/
|
||||
export function useProfileSectionDeltas(
|
||||
config: FrigateConfig | undefined,
|
||||
cameraName: string | undefined,
|
||||
profileName: string | undefined,
|
||||
sectionPath: string,
|
||||
): FieldDelta[] {
|
||||
return useMemo(() => {
|
||||
if (!config?.cameras || !cameraName || !profileName || !sectionPath) {
|
||||
return [];
|
||||
}
|
||||
const cameraConfig = config.cameras[cameraName];
|
||||
if (!cameraConfig) return [];
|
||||
|
||||
const profileSection = (
|
||||
cameraConfig.profiles?.[profileName] as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
)?.[sectionPath];
|
||||
if (profileSection == null) return [];
|
||||
|
||||
const sectionMeta = OVERRIDABLE_SECTIONS.find((s) => s.key === sectionPath);
|
||||
const compareFields = sectionMeta?.compareFields;
|
||||
|
||||
const baseValue = collapseEmpty(
|
||||
normalizeConfigValue(
|
||||
getBaseCameraSectionValue(config, cameraName, sectionPath),
|
||||
),
|
||||
);
|
||||
const profileValue = collapseEmpty(
|
||||
normalizeConfigValue(profileSection as JsonValue),
|
||||
);
|
||||
|
||||
const hiddenFields = getEffectiveHiddenFields(
|
||||
sectionPath,
|
||||
"camera",
|
||||
config,
|
||||
);
|
||||
|
||||
const deltas: FieldDelta[] = [];
|
||||
for (const path of collectDefinedLeafPaths(profileValue)) {
|
||||
if (!isPathAllowed(path, compareFields)) continue;
|
||||
if (
|
||||
hiddenFields.some((pattern) => pathMatchesHiddenPattern(path, pattern))
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const baseField = get(baseValue, path);
|
||||
const profileField = get(profileValue, path);
|
||||
if (!isEqual(baseField, profileField)) {
|
||||
deltas.push({
|
||||
fieldPath: path,
|
||||
globalValue: baseField,
|
||||
cameraValue: profileField,
|
||||
profileName,
|
||||
});
|
||||
}
|
||||
}
|
||||
return deltas;
|
||||
}, [config, cameraName, profileName, sectionPath]);
|
||||
}
|
||||
|
||||
@@ -24,7 +24,7 @@ const getSchemaDefinitions = (schema: RJSFSchema): Record<string, RJSFSchema> =>
|
||||
* Extracts and resolves a section schema from the full config schema
|
||||
* Uses caching to avoid repeated expensive resolution
|
||||
*/
|
||||
function extractSectionSchema(
|
||||
export function extractSectionSchema(
|
||||
schema: RJSFSchema,
|
||||
sectionPath: string,
|
||||
level: "global" | "camera",
|
||||
|
||||
@@ -38,6 +38,7 @@ const localeMap: Record<string, () => Promise<Locale>> = {
|
||||
th: () => import("date-fns/locale/th").then((module) => module.th),
|
||||
ca: () => import("date-fns/locale/ca").then((module) => module.ca),
|
||||
hr: () => import("date-fns/locale/hr").then((module) => module.hr),
|
||||
bs: () => import("date-fns/locale/bs").then((module) => module.bs),
|
||||
sl: () => import("date-fns/locale/sl").then((module) => module.sl),
|
||||
};
|
||||
|
||||
|
||||
@@ -28,6 +28,14 @@ export default function useNavigation(
|
||||
});
|
||||
const isAdmin = useIsAdmin();
|
||||
|
||||
const hasChatAgent = useMemo(
|
||||
() =>
|
||||
Object.values(config?.genai ?? {}).some((agent) =>
|
||||
agent?.roles?.includes("chat"),
|
||||
),
|
||||
[config?.genai],
|
||||
);
|
||||
|
||||
return useMemo(
|
||||
() =>
|
||||
[
|
||||
@@ -89,9 +97,9 @@ export default function useNavigation(
|
||||
icon: MdChat,
|
||||
title: "menu.chat",
|
||||
url: "/chat",
|
||||
enabled: isDesktop && isAdmin && config?.genai?.model !== "none",
|
||||
enabled: isDesktop && isAdmin && hasChatAgent,
|
||||
},
|
||||
] as NavData[],
|
||||
[config?.face_recognition?.enabled, config?.genai?.model, variant, isAdmin],
|
||||
[config?.face_recognition?.enabled, hasChatAgent, variant, isAdmin],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback, useRef } from "react";
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
|
||||
type OptimisticStateResult<T> = [T, (newValue: T) => void];
|
||||
|
||||
@@ -8,37 +8,32 @@ const useOptimisticState = <T>(
|
||||
delay: number = 20,
|
||||
): OptimisticStateResult<T> => {
|
||||
const [optimisticValue, setOptimisticValue] = useState<T>(currentState);
|
||||
const debounceTimeout = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
const handleValueChange = useCallback(
|
||||
(newValue: T) => {
|
||||
// Update the optimistic value immediately
|
||||
setOptimisticValue(newValue);
|
||||
|
||||
// Clear any pending debounce timeout
|
||||
if (debounceTimeout.current) {
|
||||
clearTimeout(debounceTimeout.current);
|
||||
}
|
||||
|
||||
// Set a new debounce timeout
|
||||
debounceTimeout.current = setTimeout(() => {
|
||||
// Update the actual value using the provided setter function
|
||||
setState(newValue);
|
||||
}, delay);
|
||||
},
|
||||
[delay, setState],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (debounceTimeout.current) {
|
||||
clearTimeout(debounceTimeout.current);
|
||||
}
|
||||
};
|
||||
const handleValueChange = useCallback((newValue: T) => {
|
||||
// Update the optimistic value immediately
|
||||
setOptimisticValue(newValue);
|
||||
}, []);
|
||||
|
||||
// Push the optimistic value to the real setter after the delay. Scoping
|
||||
// this to an effect keyed on optimisticValue ensures the cleanup only
|
||||
// cancels the timer for the value it scheduled — so StrictMode's
|
||||
// effect-rerun (and future re-running mechanisms) reschedules cleanly
|
||||
// instead of dropping the pending update on the floor.
|
||||
useEffect(() => {
|
||||
if (currentState != optimisticValue) {
|
||||
if (Object.is(optimisticValue, currentState)) {
|
||||
return;
|
||||
}
|
||||
const id = setTimeout(() => setState(optimisticValue), delay);
|
||||
return () => clearTimeout(id);
|
||||
}, [optimisticValue, currentState, delay, setState]);
|
||||
|
||||
// External updates to currentState should win over a stale optimistic value.
|
||||
// The guard matters under StrictMode: this effect's re-run captures the
|
||||
// *old* currentState in its closure, so without the equality check it
|
||||
// would clobber an optimistic update that another effect (e.g. a search
|
||||
// param sync) made earlier in the same commit.
|
||||
useEffect(() => {
|
||||
if (!Object.is(currentState, optimisticValue)) {
|
||||
setOptimisticValue(currentState);
|
||||
}
|
||||
// sometimes an external action will cause the currentState to change
|
||||
|
||||
@@ -89,7 +89,7 @@ export default function useStats(stats: FrigateStats | undefined) {
|
||||
}
|
||||
|
||||
const cameraName = config.cameras?.[name]?.friendly_name ?? name;
|
||||
if (config.cameras[name].enabled && cam["camera_fps"] == 0) {
|
||||
if (config.cameras?.[name]?.enabled && cam["camera_fps"] == 0) {
|
||||
problems.push({
|
||||
text: t("stats.cameraIsOffline", {
|
||||
camera: capitalizeFirstLetter(capitalizeAll(cameraName)),
|
||||
|
||||
@@ -29,6 +29,7 @@ export const supportedLanguageKeys = [
|
||||
"tr",
|
||||
"pl",
|
||||
"hr",
|
||||
"bs",
|
||||
"sk",
|
||||
"sl",
|
||||
"lt",
|
||||
|
||||
+167
-116
@@ -1,7 +1,7 @@
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { FaArrowUpLong, FaStop } from "react-icons/fa6";
|
||||
import { LuCircleAlert } from "react-icons/lu";
|
||||
import { LuCircleAlert, LuMessageSquarePlus } from "react-icons/lu";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
|
||||
import axios from "axios";
|
||||
@@ -12,7 +12,9 @@ import { ChatStartingState } from "@/components/chat/ChatStartingState";
|
||||
import { ChatAttachmentChip } from "@/components/chat/ChatAttachmentChip";
|
||||
import { ChatQuickReplies } from "@/components/chat/ChatQuickReplies";
|
||||
import { ChatPaperclipButton } from "@/components/chat/ChatPaperclipButton";
|
||||
import type { ChatMessage } from "@/types/chat";
|
||||
import ChatSettings from "@/components/chat/ChatSettings";
|
||||
import type { ChatMessage, ShowStatsMode } from "@/types/chat";
|
||||
import { usePersistence } from "@/hooks/use-persistence";
|
||||
import {
|
||||
getEventIdsFromSearchObjectsToolCalls,
|
||||
getFindSimilarObjectsFromToolCalls,
|
||||
@@ -27,6 +29,14 @@ export default function ChatPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [attachedEventId, setAttachedEventId] = useState<string | null>(null);
|
||||
const [showStats, setShowStats] = usePersistence<ShowStatsMode>(
|
||||
"chat-show-stats",
|
||||
"while_generating",
|
||||
);
|
||||
const [autoScroll, setAutoScroll] = usePersistence<boolean>(
|
||||
"chat-auto-scroll",
|
||||
true,
|
||||
);
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
@@ -36,13 +46,14 @@ export default function ChatPage() {
|
||||
|
||||
// Auto-scroll to bottom when messages change, but only if near bottom
|
||||
useEffect(() => {
|
||||
if (!autoScroll) return;
|
||||
const el = scrollRef.current;
|
||||
if (!el) return;
|
||||
const isNearBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 150;
|
||||
if (isNearBottom) {
|
||||
el.scrollTo({ top: el.scrollHeight, behavior: "smooth" });
|
||||
}
|
||||
}, [messages]);
|
||||
}, [messages, autoScroll]);
|
||||
|
||||
const submitConversation = useCallback(
|
||||
async (messagesToSend: ChatMessage[]) => {
|
||||
@@ -125,6 +136,16 @@ export default function ChatPage() {
|
||||
setIsLoading(false);
|
||||
}, []);
|
||||
|
||||
const startNewChat = useCallback(() => {
|
||||
abortRef.current?.abort();
|
||||
abortRef.current = null;
|
||||
setIsLoading(false);
|
||||
setMessages([]);
|
||||
setInput("");
|
||||
setAttachedEventId(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
|
||||
const handleEditSubmit = useCallback(
|
||||
(messageIndex: number, newContent: string) => {
|
||||
const newList: ChatMessage[] = [
|
||||
@@ -140,127 +161,157 @@ export default function ChatPage() {
|
||||
setAttachedEventId(null);
|
||||
}, []);
|
||||
|
||||
const hasStarted = messages.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex size-full justify-center p-2 md:p-4">
|
||||
<div className="flex size-full flex-col xl:w-[50%] 3xl:w-[35%]">
|
||||
{messages.length === 0 ? (
|
||||
<ChatStartingState
|
||||
onSendMessage={(message) => {
|
||||
setInput("");
|
||||
submitConversation([{ role: "user", content: message }]);
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-container flex min-h-0 w-full flex-1 flex-col gap-3 overflow-y-auto"
|
||||
>
|
||||
{messages.map((msg, i) => {
|
||||
const isLastAssistant =
|
||||
i === messages.length - 1 && msg.role === "assistant";
|
||||
const isComplete =
|
||||
msg.role === "user" || !isLoading || !isLastAssistant;
|
||||
const hasToolCalls = msg.toolCalls && msg.toolCalls.length > 0;
|
||||
const hasContent = !!msg.content?.trim();
|
||||
const showProcessing =
|
||||
isLastAssistant && isLoading && !hasContent;
|
||||
<div className="flex size-full flex-col">
|
||||
<div className="flex shrink-0 items-center justify-end gap-2 px-2 pb-3 pt-2 md:px-4 md:pt-4">
|
||||
{hasStarted && (
|
||||
<Button
|
||||
className="flex items-center md:gap-2"
|
||||
aria-label={t("new_chat")}
|
||||
size="sm"
|
||||
onClick={startNewChat}
|
||||
>
|
||||
<LuMessageSquarePlus className="text-secondary-foreground" />
|
||||
<span className="hidden md:inline">{t("new_chat")}</span>
|
||||
</Button>
|
||||
)}
|
||||
<ChatSettings
|
||||
showStats={showStats ?? "while_generating"}
|
||||
setShowStats={setShowStats}
|
||||
autoScroll={autoScroll ?? true}
|
||||
setAutoScroll={setAutoScroll}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref={scrollRef}
|
||||
className="scrollbar-container flex min-h-0 flex-1 flex-col overflow-y-auto"
|
||||
>
|
||||
<div className="flex flex-1 justify-center px-2 md:px-4">
|
||||
<div className="flex w-full flex-col xl:w-[50%] 3xl:w-[35%]">
|
||||
{hasStarted ? (
|
||||
<div className="flex w-full flex-1 flex-col gap-3 pb-3">
|
||||
{messages.map((msg, i) => {
|
||||
const isLastAssistant =
|
||||
i === messages.length - 1 && msg.role === "assistant";
|
||||
const isComplete =
|
||||
msg.role === "user" || !isLoading || !isLastAssistant;
|
||||
const hasToolCalls =
|
||||
msg.toolCalls && msg.toolCalls.length > 0;
|
||||
const hasContent = !!msg.content?.trim();
|
||||
const showProcessing =
|
||||
isLastAssistant && isLoading && !hasContent;
|
||||
|
||||
// Hide empty placeholder only when there are no tool calls yet
|
||||
if (
|
||||
isLastAssistant &&
|
||||
isLoading &&
|
||||
!hasContent &&
|
||||
!hasToolCalls
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4"
|
||||
>
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.32s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.16s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60" />
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
{msg.role === "assistant" && hasToolCalls && (
|
||||
<ToolCallsGroup toolCalls={msg.toolCalls!} />
|
||||
)}
|
||||
{showProcessing ? (
|
||||
<div className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4">
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60" />
|
||||
// Hide empty placeholder only when there are no tool calls yet
|
||||
if (
|
||||
isLastAssistant &&
|
||||
isLoading &&
|
||||
!hasContent &&
|
||||
!hasToolCalls
|
||||
)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4"
|
||||
>
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.32s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.16s]" />
|
||||
<span className="size-2.5 animate-bounce rounded-full bg-muted-foreground/60" />
|
||||
</div>
|
||||
) : (
|
||||
<MessageBubble
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
messageIndex={i}
|
||||
onEditSubmit={
|
||||
msg.role === "user" ? handleEditSubmit : undefined
|
||||
}
|
||||
isComplete={isComplete}
|
||||
/>
|
||||
)}
|
||||
{msg.role === "assistant" &&
|
||||
isComplete &&
|
||||
(() => {
|
||||
const similar = getFindSimilarObjectsFromToolCalls(
|
||||
msg.toolCalls,
|
||||
);
|
||||
if (similar) {
|
||||
);
|
||||
|
||||
return (
|
||||
<div key={i} className="flex flex-col gap-2">
|
||||
{msg.role === "assistant" && hasToolCalls && (
|
||||
<ToolCallsGroup toolCalls={msg.toolCalls!} />
|
||||
)}
|
||||
{showProcessing ? (
|
||||
<div className="flex items-center gap-2 self-start rounded-2xl bg-muted px-5 py-4">
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.3s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60 [animation-delay:-0.15s]" />
|
||||
<span className="size-2 animate-bounce rounded-full bg-muted-foreground/60" />
|
||||
</div>
|
||||
) : (
|
||||
<MessageBubble
|
||||
role={msg.role}
|
||||
content={msg.content}
|
||||
messageIndex={i}
|
||||
onEditSubmit={
|
||||
msg.role === "user" ? handleEditSubmit : undefined
|
||||
}
|
||||
isComplete={isComplete}
|
||||
stats={msg.stats}
|
||||
showStats={showStats}
|
||||
/>
|
||||
)}
|
||||
{msg.role === "assistant" &&
|
||||
isComplete &&
|
||||
(() => {
|
||||
const similar = getFindSimilarObjectsFromToolCalls(
|
||||
msg.toolCalls,
|
||||
);
|
||||
if (similar) {
|
||||
return (
|
||||
<ChatEventThumbnailsRow
|
||||
events={similar.results}
|
||||
anchor={similar.anchor}
|
||||
onAttach={setAttachedEventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const events = getEventIdsFromSearchObjectsToolCalls(
|
||||
msg.toolCalls,
|
||||
);
|
||||
return (
|
||||
<ChatEventThumbnailsRow
|
||||
events={similar.results}
|
||||
anchor={similar.anchor}
|
||||
events={events}
|
||||
onAttach={setAttachedEventId}
|
||||
/>
|
||||
);
|
||||
}
|
||||
const events = getEventIdsFromSearchObjectsToolCalls(
|
||||
msg.toolCalls,
|
||||
);
|
||||
return (
|
||||
<ChatEventThumbnailsRow
|
||||
events={events}
|
||||
onAttach={setAttachedEventId}
|
||||
/>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<p
|
||||
className="flex items-center gap-1.5 self-start text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<LuCircleAlert className="size-3.5 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{messages.length > 0 && (
|
||||
<ChatEntry
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
sendMessage={sendMessage}
|
||||
isLoading={isLoading}
|
||||
placeholder={t("placeholder")}
|
||||
attachedEventId={attachedEventId}
|
||||
onClearAttachment={handleClearAttachment}
|
||||
onAttach={setAttachedEventId}
|
||||
onStop={stopGeneration}
|
||||
recentEventIds={recentEventIds}
|
||||
/>
|
||||
)}
|
||||
})()}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{error && (
|
||||
<p
|
||||
className="flex items-center gap-1.5 self-start text-sm text-destructive"
|
||||
role="alert"
|
||||
>
|
||||
<LuCircleAlert className="size-3.5 shrink-0" />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<ChatStartingState
|
||||
onSendMessage={(message) => {
|
||||
setInput("");
|
||||
submitConversation([{ role: "user", content: message }]);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{hasStarted && (
|
||||
<div className="flex shrink-0 justify-center p-2 md:px-4 md:pb-4">
|
||||
<div className="flex w-full xl:w-[50%] 3xl:w-[35%]">
|
||||
<ChatEntry
|
||||
input={input}
|
||||
setInput={setInput}
|
||||
sendMessage={sendMessage}
|
||||
isLoading={isLoading}
|
||||
placeholder={t("placeholder")}
|
||||
attachedEventId={attachedEventId}
|
||||
onClearAttachment={handleClearAttachment}
|
||||
onAttach={setAttachedEventId}
|
||||
onStop={stopGeneration}
|
||||
recentEventIds={recentEventIds}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -298,7 +349,7 @@ function ChatEntry({
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mt-2 flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
|
||||
<div className="flex w-full flex-col items-stretch justify-center gap-2 rounded-xl bg-secondary p-3">
|
||||
{attachedEventId && (
|
||||
<div className="flex items-center">
|
||||
<ChatAttachmentChip
|
||||
|
||||
@@ -24,6 +24,7 @@ import useSWR from "swr";
|
||||
import useSWRInfinite from "swr/infinite";
|
||||
import { useDocDomain } from "@/hooks/use-doc-domain";
|
||||
import { JINA_EMBEDDING_MODELS } from "@/lib/const";
|
||||
import { useDateLocale } from "@/hooks/use-date-locale";
|
||||
|
||||
const API_LIMIT = 25;
|
||||
|
||||
@@ -43,6 +44,8 @@ export default function Explore() {
|
||||
const { t } = useTranslation(["views/explore"]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
|
||||
const dateLocale = useDateLocale();
|
||||
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
revalidateOnFocus: false,
|
||||
});
|
||||
@@ -417,7 +420,10 @@ export default function Explore() {
|
||||
)}
|
||||
</div>
|
||||
{reindexState.time_remaining >= 0 &&
|
||||
(formatSecondsToDuration(reindexState.time_remaining) ||
|
||||
(formatSecondsToDuration(
|
||||
reindexState.time_remaining,
|
||||
dateLocale,
|
||||
) ||
|
||||
t(
|
||||
"exploreIsUnavailable.embeddingsReindexing.finishingShortly",
|
||||
))}
|
||||
|
||||
+79
-43
@@ -5,6 +5,7 @@ import {
|
||||
CaseCard,
|
||||
ExportCard,
|
||||
} from "@/components/card/ExportCard";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogCancel,
|
||||
@@ -57,6 +58,7 @@ import { useTranslation } from "react-i18next";
|
||||
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
import {
|
||||
LuDownload,
|
||||
LuFolderPlus,
|
||||
LuFolderX,
|
||||
LuPencil,
|
||||
@@ -777,54 +779,76 @@ function Exports() {
|
||||
filters={["cameras"]}
|
||||
onUpdateFilter={setExportFilter}
|
||||
/>
|
||||
{isAdmin && (
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
<div className="flex items-center gap-1 md:gap-2">
|
||||
{(exportsByCase[selectedCase.id]?.length ?? 0) > 0 && (
|
||||
<Button
|
||||
asChild
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
aria-label={t("toolbar.addExport")}
|
||||
onClick={() => setCaseForAddExport(selectedCase)}
|
||||
aria-label={t("button.download", { ns: "common" })}
|
||||
>
|
||||
<LuPlus className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("toolbar.addExport")}
|
||||
</div>
|
||||
)}
|
||||
<a
|
||||
download
|
||||
href={`${baseUrl}api/cases/${selectedCase.id}/download`}
|
||||
>
|
||||
<LuDownload className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("button.download", { ns: "common" })}
|
||||
</div>
|
||||
)}
|
||||
</a>
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
aria-label={t("toolbar.editCase")}
|
||||
onClick={() =>
|
||||
setCaseDialog({
|
||||
mode: "edit",
|
||||
exportCase: selectedCase,
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuPencil className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("toolbar.editCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
aria-label={t("toolbar.deleteCase")}
|
||||
onClick={() => setCaseToDelete(selectedCase)}
|
||||
>
|
||||
<LuTrash2 className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("toolbar.deleteCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{isAdmin && (
|
||||
<>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
aria-label={t("toolbar.addExport")}
|
||||
onClick={() => setCaseForAddExport(selectedCase)}
|
||||
>
|
||||
<LuPlus className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("toolbar.addExport")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
aria-label={t("toolbar.editCase")}
|
||||
onClick={() =>
|
||||
setCaseDialog({
|
||||
mode: "edit",
|
||||
exportCase: selectedCase,
|
||||
})
|
||||
}
|
||||
>
|
||||
<LuPencil className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("toolbar.editCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
className="flex items-center gap-2 p-2"
|
||||
size="sm"
|
||||
aria-label={t("toolbar.deleteCase")}
|
||||
onClick={() => setCaseToDelete(selectedCase)}
|
||||
>
|
||||
<LuTrash2 className="text-secondary-foreground" />
|
||||
{!isMobile && (
|
||||
<div className="text-primary">
|
||||
{t("toolbar.deleteCase")}
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
@@ -841,6 +865,7 @@ function Exports() {
|
||||
search={search}
|
||||
selectedExports={selectedExports}
|
||||
selectionMode={selectionMode}
|
||||
isLoading={cases === undefined || rawExports === undefined}
|
||||
onSelectExport={onSelectExport}
|
||||
setSelected={setSelected}
|
||||
renameClip={onHandleRename}
|
||||
@@ -859,6 +884,7 @@ function Exports() {
|
||||
activeJobs={activeJobsByCase["none"] || []}
|
||||
selectedExports={selectedExports}
|
||||
selectionMode={selectionMode}
|
||||
isLoading={cases === undefined || rawExports === undefined}
|
||||
onSelectExport={onSelectExport}
|
||||
setSelectedCaseId={setSelectedCaseId}
|
||||
setSelected={setSelected}
|
||||
@@ -880,6 +906,7 @@ type AllExportsViewProps = {
|
||||
activeJobs: ExportJob[];
|
||||
selectedExports: Export[];
|
||||
selectionMode: boolean;
|
||||
isLoading: boolean;
|
||||
onSelectExport: (e: Export) => void;
|
||||
setSelectedCaseId: (id: string) => void;
|
||||
setSelected: (e: Export) => void;
|
||||
@@ -896,6 +923,7 @@ function AllExportsView({
|
||||
activeJobs,
|
||||
selectedExports,
|
||||
selectionMode,
|
||||
isLoading,
|
||||
onSelectExport,
|
||||
setSelectedCaseId,
|
||||
setSelected,
|
||||
@@ -1004,6 +1032,8 @@ function AllExportsView({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2" />
|
||||
) : (
|
||||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
||||
<LuFolderX className="size-16" />
|
||||
@@ -1023,6 +1053,7 @@ type CaseViewProps = {
|
||||
search: string;
|
||||
selectedExports: Export[];
|
||||
selectionMode: boolean;
|
||||
isLoading: boolean;
|
||||
onSelectExport: (e: Export) => void;
|
||||
setSelected: (e: Export) => void;
|
||||
renameClip: (id: string, update: string) => void;
|
||||
@@ -1040,6 +1071,7 @@ function CaseView({
|
||||
search,
|
||||
selectedExports,
|
||||
selectionMode,
|
||||
isLoading,
|
||||
onSelectExport,
|
||||
setSelected,
|
||||
renameClip,
|
||||
@@ -1178,6 +1210,10 @@ function CaseView({
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : isLoading ? (
|
||||
<div className="flex min-h-[16rem] flex-1 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex min-h-[16rem] flex-col items-center justify-center p-6 text-center">
|
||||
<LuFolderX className="size-12" />
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import AddFaceIcon from "@/components/icons/AddFaceIcon";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import { EmptyCard } from "@/components/card/EmptyCard";
|
||||
import CreateFaceWizardDialog from "@/components/overlay/detail/FaceCreateWizardDialog";
|
||||
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
|
||||
import UploadImageDialog from "@/components/overlay/dialog/UploadImageDialog";
|
||||
@@ -473,7 +474,9 @@ export default function FaceLibrary() {
|
||||
attemptImages={trainImages}
|
||||
faceNames={faces}
|
||||
selectedFaces={selectedFaces}
|
||||
isLoading={faceData === undefined}
|
||||
onClickFaces={onClickFaces}
|
||||
onAddFace={() => setAddFace(true)}
|
||||
onRefresh={refreshFaces}
|
||||
/>
|
||||
) : (
|
||||
@@ -594,7 +597,7 @@ function LibrarySelector({
|
||||
forbiddenErrorMessage={t("description.nameCannotContainHash")}
|
||||
/>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="flex justify-between smart-capitalize">
|
||||
{pageTitle}
|
||||
@@ -691,7 +694,9 @@ type TrainingGridProps = {
|
||||
attemptImages: string[];
|
||||
faceNames: string[];
|
||||
selectedFaces: string[];
|
||||
isLoading: boolean;
|
||||
onClickFaces: (images: string[], ctrl: boolean) => void;
|
||||
onAddFace: () => void;
|
||||
onRefresh: (
|
||||
data?:
|
||||
| FaceLibraryData
|
||||
@@ -708,7 +713,9 @@ function TrainingGrid({
|
||||
attemptImages,
|
||||
faceNames,
|
||||
selectedFaces,
|
||||
isLoading,
|
||||
onClickFaces,
|
||||
onAddFace,
|
||||
onRefresh,
|
||||
}: TrainingGridProps) {
|
||||
const { t } = useTranslation(["views/faceLibrary"]);
|
||||
@@ -762,6 +769,25 @@ function TrainingGrid({
|
||||
]);
|
||||
|
||||
if (attemptImages.length == 0) {
|
||||
if (isLoading) {
|
||||
return (
|
||||
<ActivityIndicator className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 items-center text-center" />
|
||||
);
|
||||
}
|
||||
|
||||
if (faceNames.length == 0) {
|
||||
return (
|
||||
<EmptyCard
|
||||
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 items-center text-center"
|
||||
icon={<AddFaceIcon className="size-16" />}
|
||||
title={t("train.emptyNoLibrary.title")}
|
||||
description={t("train.emptyNoLibrary.description")}
|
||||
buttonText={t("button.addFace")}
|
||||
onClick={onAddFace}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center justify-center text-center">
|
||||
<LuFolderCheck className="size-16" />
|
||||
|
||||
+151
-87
@@ -42,7 +42,9 @@ import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import { getIconForLabel } from "@/utils/iconUtil";
|
||||
import { getTranslatedLabel } from "@/utils/i18n";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Progress } from "@/components/ui/progress";
|
||||
import { ObjectType } from "@/types/ws";
|
||||
import { useJobStatus } from "@/api/ws";
|
||||
import WsMessageFeed from "@/components/ws/WsMessageFeed";
|
||||
import { ConfigSectionTemplate } from "@/components/config-form/sections/ConfigSectionTemplate";
|
||||
|
||||
@@ -53,6 +55,7 @@ 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 { useConfigSchema } from "@/hooks/use-config-schema";
|
||||
import DebugDrawingLayer from "@/components/overlay/DebugDrawingLayer";
|
||||
import { IoMdArrowRoundBack } from "react-icons/io";
|
||||
|
||||
@@ -65,6 +68,15 @@ type DebugReplayStatus = {
|
||||
live_ready: boolean;
|
||||
};
|
||||
|
||||
type DebugReplayJobResults = {
|
||||
current_step: "preparing_clip" | "starting_camera" | null;
|
||||
progress_percent: number | null;
|
||||
source_camera: string | null;
|
||||
replay_camera_name: string | null;
|
||||
start_ts: number | null;
|
||||
end_ts: number | null;
|
||||
};
|
||||
|
||||
type DebugOptions = {
|
||||
bbox: boolean;
|
||||
timestamp: boolean;
|
||||
@@ -105,8 +117,6 @@ const DEBUG_OPTION_I18N_KEY: Record<keyof DebugOptions, string> = {
|
||||
paths: "paths",
|
||||
};
|
||||
|
||||
const REPLAY_INIT_SKELETON_TIMEOUT_MS = 8000;
|
||||
|
||||
export default function Replay() {
|
||||
const { t } = useTranslation(["views/replay", "views/settings", "common"]);
|
||||
const navigate = useNavigate();
|
||||
@@ -119,6 +129,9 @@ export default function Replay() {
|
||||
} = useSWR<DebugReplayStatus>("debug_replay/status", {
|
||||
refreshInterval: 1000,
|
||||
});
|
||||
const { payload: replayJob } =
|
||||
useJobStatus<DebugReplayJobResults>("debug_replay");
|
||||
const configSchema = useConfigSchema();
|
||||
const [isInitializing, setIsInitializing] = useState(true);
|
||||
|
||||
// Refresh status immediately on mount to avoid showing "no session" briefly
|
||||
@@ -130,12 +143,6 @@ export default function Replay() {
|
||||
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);
|
||||
@@ -160,11 +167,7 @@ export default function Replay() {
|
||||
axios
|
||||
.post("debug_replay/stop")
|
||||
.then(() => {
|
||||
toast.success(t("dialog.toast.stopped"), {
|
||||
position: "top-center",
|
||||
});
|
||||
refreshStatus();
|
||||
navigate("/review");
|
||||
})
|
||||
.catch((error) => {
|
||||
const errorMessage =
|
||||
@@ -178,7 +181,7 @@ export default function Replay() {
|
||||
.finally(() => {
|
||||
setIsStopping(false);
|
||||
});
|
||||
}, [navigate, refreshStatus, t]);
|
||||
}, [refreshStatus, t]);
|
||||
|
||||
// Camera activity for the replay camera
|
||||
const { data: config } = useSWR<FrigateConfig>("config", {
|
||||
@@ -191,35 +194,10 @@ export default function Replay() {
|
||||
|
||||
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 "";
|
||||
@@ -237,8 +215,39 @@ export default function Replay() {
|
||||
);
|
||||
}
|
||||
|
||||
// No active session
|
||||
if (!status?.active) {
|
||||
// Startup error (job failed). Only show when status.active is also true so
|
||||
// we don't surface stale failed jobs after a session ended cleanly.
|
||||
if (replayJob?.status === "failed" && status?.active) {
|
||||
return (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-4 p-8">
|
||||
<Heading as="h2" className="text-center">
|
||||
{t("page.startError.title")}
|
||||
</Heading>
|
||||
{replayJob.error_message && (
|
||||
<p className="max-w-xl text-center text-sm text-muted-foreground">
|
||||
{replayJob.error_message}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => {
|
||||
axios
|
||||
.post("debug_replay/stop")
|
||||
.catch(() => {})
|
||||
.finally(() => navigate("/review"));
|
||||
}}
|
||||
>
|
||||
{t("page.startError.back")}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// No active session. Also covers the brief window between the runner
|
||||
// pushing job.status = "cancelled" via WS and the next SWR refresh
|
||||
// flipping status.active to false — without this, render falls through
|
||||
// to the full replay UI and you see a flash of it before stop completes.
|
||||
if (!status?.active || replayJob?.status === "cancelled") {
|
||||
return (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-4 p-8">
|
||||
<MdReplay className="size-12" />
|
||||
@@ -255,6 +264,52 @@ export default function Replay() {
|
||||
);
|
||||
}
|
||||
|
||||
// Startup in progress (job is running). The session is active but the
|
||||
// replay camera isn't ready yet; show progress / phase from the job.
|
||||
const startupStep =
|
||||
replayJob?.status === "running"
|
||||
? (replayJob.results?.current_step ?? null)
|
||||
: null;
|
||||
if (startupStep === "preparing_clip" || startupStep === "starting_camera") {
|
||||
const phaseTitle =
|
||||
startupStep === "preparing_clip"
|
||||
? t("page.preparingClip")
|
||||
: t("page.startingCamera");
|
||||
const progressPercent = replayJob?.results?.progress_percent ?? null;
|
||||
const showProgressBar =
|
||||
startupStep === "preparing_clip" && progressPercent != null;
|
||||
return (
|
||||
<div className="flex size-full flex-col items-center justify-center gap-4 p-8">
|
||||
{showProgressBar ? (
|
||||
<div className="flex w-64 flex-col items-center gap-2">
|
||||
<Progress value={progressPercent ?? 0} />
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{Math.round(progressPercent ?? 0)}%
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<ActivityIndicator className="size-8" />
|
||||
)}
|
||||
<Heading as="h3" className="text-center">
|
||||
{phaseTitle}
|
||||
</Heading>
|
||||
{startupStep === "preparing_clip" && (
|
||||
<p className="max-w-md text-center text-sm text-muted-foreground">
|
||||
{t("page.preparingClipDesc")}
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={isStopping}
|
||||
onClick={handleStop}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex size-full flex-col overflow-hidden">
|
||||
<Toaster position="top-center" closeButton={true} />
|
||||
@@ -345,27 +400,30 @@ export default function Replay() {
|
||||
) : (
|
||||
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 && (
|
||||
{status.live_ready ? (
|
||||
<>
|
||||
<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
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<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">
|
||||
@@ -595,32 +653,38 @@ export default function Replay() {
|
||||
{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>
|
||||
{configSchema == null ? (
|
||||
<div className="flex h-40 items-center justify-center">
|
||||
<ActivityIndicator />
|
||||
</div>
|
||||
) : (
|
||||
<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>
|
||||
|
||||
@@ -28,11 +28,7 @@ import useOptimisticState from "@/hooks/use-optimistic-state";
|
||||
import { isMobile } from "react-device-detect";
|
||||
import { FaVideo } from "react-icons/fa";
|
||||
import { CameraConfig, FrigateConfig } from "@/types/frigateConfig";
|
||||
import type {
|
||||
ConfigSectionData,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
} from "@/types/configForm";
|
||||
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
|
||||
import useSWR from "swr";
|
||||
import FilterSwitch from "@/components/filter/FilterSwitch";
|
||||
import { ZoneMaskFilterButton } from "@/components/filter/ZoneMaskFilter";
|
||||
@@ -93,6 +89,7 @@ import { mutate } from "swr";
|
||||
import { RJSFSchema } from "@rjsf/utils";
|
||||
import {
|
||||
buildConfigDataForPath,
|
||||
flattenOverrides,
|
||||
parseProfileFromSectionPath,
|
||||
prepareSectionSavePayload,
|
||||
PROFILE_ELIGIBLE_SECTIONS,
|
||||
@@ -190,25 +187,6 @@ const parsePendingDataKey = (pendingDataKey: string) => {
|
||||
};
|
||||
};
|
||||
|
||||
const flattenOverrides = (
|
||||
value: JsonValue | undefined,
|
||||
path: string[] = [],
|
||||
): Array<{ path: string; value: JsonValue }> => {
|
||||
if (value === undefined) return [];
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return [{ path: path.join("."), value }];
|
||||
}
|
||||
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) {
|
||||
return [{ path: path.join("."), value: {} }];
|
||||
}
|
||||
|
||||
return entries.flatMap(([key, entryValue]) =>
|
||||
flattenOverrides(entryValue, [...path, key]),
|
||||
);
|
||||
};
|
||||
|
||||
const createSectionPage = (
|
||||
sectionKey: string,
|
||||
level: "global" | "camera",
|
||||
@@ -907,6 +885,7 @@ export default function Settings() {
|
||||
|
||||
// Refresh config from server once
|
||||
await mutate("config");
|
||||
mutate("config/raw_paths");
|
||||
|
||||
// Clear hasChanges in sidebar for all successfully saved sections
|
||||
if (savedKeys.length > 0) {
|
||||
@@ -1435,7 +1414,7 @@ export default function Settings() {
|
||||
/>
|
||||
)}
|
||||
{showUnsavedDot && (
|
||||
<span className="inline-block size-2 rounded-full bg-danger" />
|
||||
<span className="inline-block size-2 rounded-full bg-unsaved" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
@@ -1516,7 +1495,7 @@ export default function Settings() {
|
||||
<div className="sticky bottom-0 z-50 mt-2 bg-background p-4">
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-danger">
|
||||
<span className="text-sm text-unsaved">
|
||||
{t("unsavedChanges", {
|
||||
ns: "views/settings",
|
||||
defaultValue: "You have unsaved changes",
|
||||
@@ -2018,7 +1997,6 @@ function CameraSelectButton({
|
||||
|
||||
return (
|
||||
<DropdownMenu
|
||||
modal={false}
|
||||
open={open}
|
||||
onOpenChange={(open: boolean) => {
|
||||
if (!open) {
|
||||
|
||||
@@ -8,9 +8,19 @@ export type ChatMessage = {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
toolCalls?: ToolCall[];
|
||||
stats?: ChatStats;
|
||||
};
|
||||
|
||||
export type StartingRequest = {
|
||||
label: string;
|
||||
prompt: string;
|
||||
};
|
||||
|
||||
export type ChatStats = {
|
||||
promptTokens?: number;
|
||||
completionTokens?: number;
|
||||
completionDurationMs?: number;
|
||||
tokensPerSecond?: number;
|
||||
};
|
||||
|
||||
export type ShowStatsMode = "while_generating" | "always";
|
||||
|
||||
@@ -13,6 +13,8 @@ export type JsonArray = JsonValue[];
|
||||
|
||||
export type ConfigSectionData = JsonObject;
|
||||
|
||||
export type HiddenFieldEntry = string | ((config: FrigateConfig) => string[]);
|
||||
|
||||
export type ConfigFormContext = {
|
||||
level?: "global" | "camera";
|
||||
cameraName?: string;
|
||||
@@ -42,4 +44,5 @@ export type ConfigFormContext = {
|
||||
requiresRestart?: boolean;
|
||||
t?: (key: string, options?: Record<string, unknown>) => string;
|
||||
renderers?: Record<string, RendererComponent>;
|
||||
isProfile?: boolean;
|
||||
};
|
||||
|
||||
@@ -382,6 +382,18 @@ export type AllGroupsStreamingSettings = {
|
||||
[groupName: string]: GroupStreamingSettings;
|
||||
};
|
||||
|
||||
export type GenAIRole = "chat" | "descriptions" | "embeddings";
|
||||
|
||||
export type GenAIAgentConfig = {
|
||||
api_key?: string;
|
||||
base_url?: string;
|
||||
model: string;
|
||||
provider?: string;
|
||||
roles: GenAIRole[];
|
||||
provider_options?: Record<string, unknown>;
|
||||
runtime_options?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export interface FrigateConfig {
|
||||
version: string;
|
||||
safe_mode: boolean;
|
||||
@@ -478,12 +490,7 @@ export interface FrigateConfig {
|
||||
retry_interval: number;
|
||||
};
|
||||
|
||||
genai: {
|
||||
provider: string;
|
||||
base_url?: string;
|
||||
api_key?: string;
|
||||
model: string;
|
||||
};
|
||||
genai: Record<string, GenAIAgentConfig>;
|
||||
|
||||
go2rtc: {
|
||||
streams: Record<string, string | string[]>;
|
||||
|
||||
@@ -62,7 +62,10 @@ export type ExtraProcessStats = {
|
||||
mem?: string;
|
||||
};
|
||||
|
||||
export type GpuVendor = "intel" | "amd" | "nvidia" | "rockchip" | "rpi";
|
||||
|
||||
export type GpuStats = {
|
||||
vendor?: GpuVendor;
|
||||
gpu: string;
|
||||
mem: string;
|
||||
enc?: string;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { ChatMessage, ToolCall } from "@/types/chat";
|
||||
import type { ChatMessage, ChatStats, ToolCall } from "@/types/chat";
|
||||
|
||||
export type StreamChatCallbacks = {
|
||||
/** Update the messages array (e.g. pass to setState). */
|
||||
@@ -7,14 +7,27 @@ export type StreamChatCallbacks = {
|
||||
onError: (message: string) => void;
|
||||
/** Called when the stream finishes (success or error). */
|
||||
onDone: () => void;
|
||||
/** Called when the stream emits token/timing stats. The stats are also
|
||||
* attached to the last assistant message in updateMessages, so consumers
|
||||
* can usually rely on the message itself rather than wiring this up. */
|
||||
onStats?: (stats: ChatStats) => void;
|
||||
/** Message used when fetch throws and no server error is available. */
|
||||
defaultErrorMessage?: string;
|
||||
};
|
||||
|
||||
type StatsChunk = {
|
||||
type: "stats";
|
||||
prompt_tokens?: number;
|
||||
completion_tokens?: number;
|
||||
completion_duration_ms?: number;
|
||||
tokens_per_second?: number;
|
||||
};
|
||||
|
||||
type StreamChunk =
|
||||
| { type: "error"; error: string }
|
||||
| { type: "tool_calls"; tool_calls: ToolCall[] }
|
||||
| { type: "content"; delta: string };
|
||||
| { type: "content"; delta: string }
|
||||
| StatsChunk;
|
||||
|
||||
/**
|
||||
* POST to chat/completion with stream: true, parse NDJSON stream, and invoke
|
||||
@@ -31,6 +44,7 @@ export async function streamChatCompletion(
|
||||
updateMessages,
|
||||
onError,
|
||||
onDone,
|
||||
onStats,
|
||||
defaultErrorMessage = "Something went wrong. Please try again.",
|
||||
} = callbacks;
|
||||
|
||||
@@ -95,6 +109,23 @@ export async function streamChatCompletion(
|
||||
});
|
||||
return "continue";
|
||||
}
|
||||
if (data.type === "stats") {
|
||||
const stats: ChatStats = {
|
||||
promptTokens: data.prompt_tokens,
|
||||
completionTokens: data.completion_tokens,
|
||||
completionDurationMs: data.completion_duration_ms,
|
||||
tokensPerSecond: data.tokens_per_second,
|
||||
};
|
||||
updateMessages((prev) => {
|
||||
const next = [...prev];
|
||||
const lastMsg = next[next.length - 1];
|
||||
if (lastMsg?.role === "assistant")
|
||||
next[next.length - 1] = { ...lastMsg, stats };
|
||||
return next;
|
||||
});
|
||||
onStats?.(stats);
|
||||
return "continue";
|
||||
}
|
||||
return "continue";
|
||||
};
|
||||
|
||||
|
||||
+126
-16
@@ -6,7 +6,6 @@
|
||||
|
||||
import get from "lodash/get";
|
||||
import cloneDeep from "lodash/cloneDeep";
|
||||
import merge from "lodash/merge";
|
||||
import unset from "lodash/unset";
|
||||
import isEqual from "lodash/isEqual";
|
||||
import mergeWith from "lodash/mergeWith";
|
||||
@@ -92,6 +91,32 @@ export function getBaseCameraSectionValue(
|
||||
return base !== undefined ? base : get(cam, sectionPath);
|
||||
}
|
||||
|
||||
// mergeWith customizer that replaces arrays wholesale instead of merging them
|
||||
// positionally by index. Used when the source value is meant to fully replace
|
||||
// the destination (e.g. profile overrides, section config overrides), so an
|
||||
// empty source array correctly clears the destination array.
|
||||
const replaceArraysCustomizer = (objValue: unknown, srcValue: unknown) => {
|
||||
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
|
||||
return srcValue !== undefined ? srcValue : objValue;
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
// Merge profile overrides on top of base config values. Matches the backend's
|
||||
// deep_merge(overrides, base_data) semantics: arrays are replaced wholesale by
|
||||
// the profile's value rather than merged positionally, so an empty array in a
|
||||
// profile clears the base array instead of leaving stale entries behind.
|
||||
export function mergeProfileOverrides<T extends object>(
|
||||
baseValue: T,
|
||||
profileOverrides: object,
|
||||
): T {
|
||||
return mergeWith(
|
||||
cloneDeep(baseValue),
|
||||
cloneDeep(profileOverrides),
|
||||
replaceArraysCustomizer,
|
||||
) as T;
|
||||
}
|
||||
|
||||
/** Sections that can appear inside a camera profile definition. */
|
||||
export const PROFILE_ELIGIBLE_SECTIONS = new Set([
|
||||
"audio",
|
||||
@@ -219,6 +244,32 @@ export function buildOverrides(
|
||||
return current;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// flattenOverrides — turn an overrides object into a list of leaf paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Walks a nested overrides value and produces a flat list of `{ path, value }`
|
||||
// entries, one per leaf. Used by save/preview UIs to enumerate the individual
|
||||
// fields that will be changed.
|
||||
export function flattenOverrides(
|
||||
value: JsonValue | undefined,
|
||||
path: string[] = [],
|
||||
): Array<{ path: string; value: JsonValue }> {
|
||||
if (value === undefined) return [];
|
||||
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
||||
return [{ path: path.join("."), value }];
|
||||
}
|
||||
|
||||
const entries = Object.entries(value);
|
||||
if (entries.length === 0) {
|
||||
return [{ path: path.join("."), value: {} }];
|
||||
}
|
||||
|
||||
return entries.flatMap(([key, entryValue]) =>
|
||||
flattenOverrides(entryValue, [...path, key]),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// sanitizeSectionData — normalize config values and strip hidden fields
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -228,7 +279,10 @@ export function buildOverrides(
|
||||
|
||||
// lodash `unset` treats `*` as a literal key. This helper expands wildcard
|
||||
// segments so that e.g. `"filters.*.mask"` unsets `filters.<each key>.mask`.
|
||||
function unsetWithWildcard(obj: Record<string, unknown>, path: string): void {
|
||||
export function unsetWithWildcard(
|
||||
obj: Record<string, unknown>,
|
||||
path: string,
|
||||
): void {
|
||||
if (!path.includes("*")) {
|
||||
unset(obj, path);
|
||||
return;
|
||||
@@ -535,9 +589,9 @@ export function prepareSectionSavePayload(opts: {
|
||||
baseValue &&
|
||||
typeof baseValue === "object"
|
||||
) {
|
||||
rawSectionValue = merge(
|
||||
cloneDeep(baseValue),
|
||||
cloneDeep(profileOverrides),
|
||||
rawSectionValue = mergeProfileOverrides(
|
||||
baseValue as object,
|
||||
profileOverrides as object,
|
||||
);
|
||||
} else {
|
||||
rawSectionValue = baseValue;
|
||||
@@ -561,13 +615,14 @@ export function prepareSectionSavePayload(opts: {
|
||||
// For profile sections, also hide restart-required fields to match
|
||||
// effectiveHiddenFields in BaseSection (prevents spurious deletion markers
|
||||
// for fields that are hidden from the form during profile editing).
|
||||
let hiddenFieldsForSanitize = sectionConfig.hiddenFields;
|
||||
if (profileInfo.isProfile && sectionConfig.restartRequired?.length) {
|
||||
const base = sectionConfig.hiddenFields ?? [];
|
||||
hiddenFieldsForSanitize = [
|
||||
...new Set([...base, ...sectionConfig.restartRequired]),
|
||||
];
|
||||
}
|
||||
const resolvedHidden = resolveHiddenFieldEntries(
|
||||
sectionConfig.hiddenFields,
|
||||
config,
|
||||
);
|
||||
const hiddenFieldsForSanitize =
|
||||
profileInfo.isProfile && sectionConfig.restartRequired?.length
|
||||
? [...new Set([...resolvedHidden, ...sectionConfig.restartRequired])]
|
||||
: resolvedHidden;
|
||||
|
||||
// Sanitize raw form data
|
||||
const rawData = sanitizeSectionData(
|
||||
@@ -645,13 +700,12 @@ const mergeSectionConfig = (
|
||||
overrides: Partial<SectionConfig> | undefined,
|
||||
): SectionConfig =>
|
||||
mergeWith({}, base ?? {}, overrides ?? {}, (objValue, srcValue, key) => {
|
||||
if (Array.isArray(objValue) || Array.isArray(srcValue)) {
|
||||
return srcValue ?? objValue;
|
||||
}
|
||||
const arrayResult = replaceArraysCustomizer(objValue, srcValue);
|
||||
if (arrayResult !== undefined) return arrayResult;
|
||||
|
||||
if (key === "uiSchema") {
|
||||
if (objValue && srcValue) {
|
||||
return merge({}, objValue, srcValue);
|
||||
return mergeWith({}, objValue, srcValue, replaceArraysCustomizer);
|
||||
}
|
||||
return srcValue ?? objValue;
|
||||
}
|
||||
@@ -676,3 +730,59 @@ export function getSectionConfig(
|
||||
: entry.camera;
|
||||
return mergeSectionConfig(entry.base, overrides);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the effective hidden-field patterns for a section. Each entry in
|
||||
* `hiddenFields` is either a literal pattern or a function that produces
|
||||
* patterns from the loaded config (e.g. `filters.<attr>` for each
|
||||
* `model.all_attributes` entry on the objects section).
|
||||
*/
|
||||
export function getEffectiveHiddenFields(
|
||||
sectionKey: string,
|
||||
level: "global" | "camera" | "replay",
|
||||
config: FrigateConfig | undefined,
|
||||
): string[] {
|
||||
return resolveHiddenFieldEntries(
|
||||
getSectionConfig(sectionKey, level).hiddenFields,
|
||||
config,
|
||||
);
|
||||
}
|
||||
|
||||
export function resolveHiddenFieldEntries(
|
||||
entries: SectionConfig["hiddenFields"] | undefined,
|
||||
config: FrigateConfig | undefined,
|
||||
): string[] {
|
||||
if (!entries || entries.length === 0) return [];
|
||||
const result: string[] = [];
|
||||
for (const entry of entries) {
|
||||
if (typeof entry === "function") {
|
||||
if (config) result.push(...entry(config));
|
||||
} else {
|
||||
result.push(entry);
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Match a delta path against a hidden-field pattern. Supports literal prefixes
|
||||
* (so a hidden field "streams" also hides "streams.foo.bar") and `*` wildcards
|
||||
* matching exactly one path segment (e.g. "filters.*.mask").
|
||||
*/
|
||||
export function pathMatchesHiddenPattern(
|
||||
path: string,
|
||||
pattern: string,
|
||||
): boolean {
|
||||
if (!pattern) return false;
|
||||
if (!pattern.includes("*")) {
|
||||
return path === pattern || path.startsWith(`${pattern}.`);
|
||||
}
|
||||
const patternSegments = pattern.split(".");
|
||||
const pathSegments = path.split(".");
|
||||
if (pathSegments.length < patternSegments.length) return false;
|
||||
for (let i = 0; i < patternSegments.length; i += 1) {
|
||||
if (patternSegments[i] === "*") continue;
|
||||
if (patternSegments[i] !== pathSegments[i]) return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -60,7 +60,7 @@ export function getLifecycleItemDescription(
|
||||
} else {
|
||||
title = t("trackingDetails.lifecycleItemDesc.attribute.other", {
|
||||
ns: "views/explore",
|
||||
label: lifecycleItem.data.label,
|
||||
label: getTranslatedLabel(lifecycleItem.data.label),
|
||||
attribute: getTranslatedLabel(
|
||||
lifecycleItem.data.attribute.replaceAll("_", " "),
|
||||
),
|
||||
|
||||
@@ -79,11 +79,11 @@ const PROFILE_COLORS: ProfileColor[] = [
|
||||
bgMuted: "bg-green-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-amber-400",
|
||||
text: "text-amber-400",
|
||||
dot: "bg-amber-400",
|
||||
border: "border-amber-400",
|
||||
bgMuted: "bg-amber-400/20",
|
||||
bg: "bg-fuchsia-500",
|
||||
text: "text-fuchsia-500",
|
||||
dot: "bg-fuchsia-500",
|
||||
border: "border-fuchsia-500",
|
||||
bgMuted: "bg-fuchsia-500/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-slate-400",
|
||||
@@ -93,11 +93,11 @@ const PROFILE_COLORS: ProfileColor[] = [
|
||||
bgMuted: "bg-slate-400/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-orange-300",
|
||||
text: "text-orange-300",
|
||||
dot: "bg-orange-300",
|
||||
border: "border-orange-300",
|
||||
bgMuted: "bg-orange-300/20",
|
||||
bg: "bg-stone-500",
|
||||
text: "text-stone-500",
|
||||
dot: "bg-stone-500",
|
||||
border: "border-stone-500",
|
||||
bgMuted: "bg-stone-500/20",
|
||||
},
|
||||
{
|
||||
bg: "bg-blue-300",
|
||||
|
||||
@@ -38,6 +38,22 @@ export function getChunkedTimeDay(timeRange: TimeRange): TimeRange[] {
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the chunk index that contains the given timestamp.
|
||||
* Uses half-open intervals [after, before) for all chunks except the last,
|
||||
* which uses a closed interval [after, before] so the terminal boundary
|
||||
* is always reachable.
|
||||
*/
|
||||
export function findChunkIndex(chunks: TimeRange[], timestamp: number): number {
|
||||
return chunks.findIndex((chunk, i) => {
|
||||
const isLast = i === chunks.length - 1;
|
||||
return (
|
||||
chunk.after <= timestamp &&
|
||||
(isLast ? chunk.before >= timestamp : chunk.before > timestamp)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function getChunkedTimeRange(
|
||||
startTimestamp: number,
|
||||
endTimestamp: number,
|
||||
|
||||
@@ -342,7 +342,7 @@ function ModelCard({ config, onClick, onUpdate, onDelete }: ModelCardProps) {
|
||||
{config.name}
|
||||
</div>
|
||||
<div className="absolute bottom-2 right-2 z-40">
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<BlurredIconButton>
|
||||
<FiMoreVertical className="size-5 text-white" />
|
||||
|
||||
@@ -698,7 +698,7 @@ function LibrarySelector({
|
||||
regexErrorMessage={t("description.invalidName")}
|
||||
/>
|
||||
|
||||
<DropdownMenu modal={false}>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button className="flex justify-between smart-capitalize">
|
||||
{pageTitle}
|
||||
|
||||
@@ -278,7 +278,7 @@ export default function EventView({
|
||||
{ playback: "realtime", image_path: review.thumb_path },
|
||||
)
|
||||
.then((response) => {
|
||||
if (response.status == 200) {
|
||||
if (response.status < 300) {
|
||||
toast.success(
|
||||
t("export.toast.success", { ns: "components/dialog" }),
|
||||
{
|
||||
@@ -1136,6 +1136,12 @@ function MotionReview({
|
||||
);
|
||||
const [isRegionFilterOpen, setIsRegionFilterOpen] = useState(false);
|
||||
|
||||
// reset filter when camera changes
|
||||
useEffect(() => {
|
||||
setMotionFilterCells(new Set());
|
||||
setPendingFilterCells(new Set());
|
||||
}, [motionPreviewsCamera]);
|
||||
|
||||
const objectReviewItems = useMemo(
|
||||
() =>
|
||||
(overlapReviewSegments ?? []).filter(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user