mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-30 23:59:02 +03:00
Miscellaneous fixes (#23279)
* use monotonic clock for detector inference duration to prevent negative values from wall clock steps * add ability to set camera's webui_url from camera management pane * Gemini send thought signature * Update docs * copy face and lpr configs from source camera to replay camera * add guard * improve dummy camera docs * remove version number * fix stale field message after reverting a conditional form field Routes field-level conditional messages through a dedicated React Context instead of merging them into uiSchema. RJSF's Form keeps state.uiSchema sticky across renders during processPendingChange (formData is updated, uiSchema is not), so a previously injected ui:messages array stays attached to a field even after the triggering condition flips back to false. Context propagation re-runs FieldTemplate directly on every provider value change, sidestepping that staleness. * add semantic search field message to note that model_size is irrelevant when embeddings provider is selected --------- Co-authored-by: Nicolas Mowen <nickmowen213@gmail.com>
This commit is contained in:
co-authored by
Nicolas Mowen
parent
a4a592b4e6
commit
0bdf5002a0
@@ -0,0 +1,13 @@
|
||||
import { createContext } from "react";
|
||||
import type { FieldConditionalMessage } from "./section-configs/types";
|
||||
|
||||
// Provides currently-active field messages to FieldTemplate without going
|
||||
// through RJSF's per-field uiSchema. RJSF caches state.uiSchema across renders
|
||||
// in a way that can leave stale ui:messages attached to a field when the
|
||||
// triggering condition flips back to false (see processPendingChange in
|
||||
// @rjsf/core Form.js — formData is updated immediately, uiSchema is not).
|
||||
// useContext re-runs consumers directly on provider value change, sidestepping
|
||||
// that staleness.
|
||||
export const FieldMessagesContext = createContext<FieldConditionalMessage[]>(
|
||||
[],
|
||||
);
|
||||
@@ -29,6 +29,22 @@ const semanticSearch: SectionConfigOverrides = {
|
||||
ctx.formData?.model === "jinav2" &&
|
||||
ctx.formData?.model_size === "small",
|
||||
},
|
||||
{
|
||||
key: "model-size-ignored-for-provider",
|
||||
field: "model_size",
|
||||
messageKey: "configMessages.semanticSearch.modelSizeIgnoredForProvider",
|
||||
severity: "info",
|
||||
position: "after",
|
||||
condition: (ctx) => {
|
||||
const model = ctx.formData?.model;
|
||||
return (
|
||||
typeof model === "string" &&
|
||||
model !== "" &&
|
||||
model !== "jinav1" &&
|
||||
model !== "jinav2"
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
uiSchema: {
|
||||
model: {
|
||||
|
||||
@@ -86,6 +86,7 @@ import type {
|
||||
} from "../section-configs/types";
|
||||
import { useConfigMessages } from "@/hooks/use-config-messages";
|
||||
import { ConfigMessageBanner } from "../ConfigMessageBanner";
|
||||
import { FieldMessagesContext } from "../FieldMessagesContext";
|
||||
|
||||
export interface SectionConfig {
|
||||
/** Field ordering within the section */
|
||||
@@ -627,44 +628,6 @@ export function ConfigSection({
|
||||
messageContext,
|
||||
);
|
||||
|
||||
// Merge field-level conditional messages into uiSchema
|
||||
const effectiveUiSchema = useMemo(() => {
|
||||
if (activeFieldMessages.length === 0) return sectionConfig.uiSchema;
|
||||
const merged = { ...(sectionConfig.uiSchema ?? {}) };
|
||||
for (const msg of activeFieldMessages) {
|
||||
const segments = msg.field.split(".");
|
||||
// Navigate to the nested uiSchema node, shallow-cloning along the way
|
||||
let node = merged;
|
||||
for (let i = 0; i < segments.length - 1; i++) {
|
||||
const seg = segments[i];
|
||||
node[seg] = { ...(node[seg] as Record<string, unknown>) };
|
||||
node = node[seg] as Record<string, unknown>;
|
||||
}
|
||||
const leafKey = segments[segments.length - 1];
|
||||
const existing = node[leafKey] as Record<string, unknown> | undefined;
|
||||
const existingMessages = ((existing?.["ui:messages"] as unknown[]) ??
|
||||
[]) as Array<{
|
||||
key: string;
|
||||
messageKey: string;
|
||||
severity: string;
|
||||
position?: string;
|
||||
}>;
|
||||
node[leafKey] = {
|
||||
...existing,
|
||||
"ui:messages": [
|
||||
...existingMessages,
|
||||
{
|
||||
key: msg.key,
|
||||
messageKey: msg.messageKey,
|
||||
severity: msg.severity,
|
||||
position: msg.position ?? "before",
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}, [sectionConfig.uiSchema, activeFieldMessages]);
|
||||
|
||||
const currentOverrides = useMemo(() => {
|
||||
if (!currentFormData || typeof currentFormData !== "object") {
|
||||
return undefined;
|
||||
@@ -1034,59 +997,61 @@ export function ConfigSection({
|
||||
const sectionContent = (
|
||||
<div className="space-y-6">
|
||||
<ConfigMessageBanner messages={activeMessages} />
|
||||
<ConfigForm
|
||||
key={formKey}
|
||||
schema={modifiedSchema}
|
||||
formData={currentFormData}
|
||||
onChange={handleChange}
|
||||
onValidationChange={setHasValidationErrors}
|
||||
fieldOrder={sectionConfig.fieldOrder}
|
||||
fieldGroups={sectionConfig.fieldGroups}
|
||||
hiddenFields={effectiveHiddenFields}
|
||||
advancedFields={sectionConfig.advancedFields}
|
||||
liveValidate={sectionConfig.liveValidate}
|
||||
uiSchema={effectiveUiSchema}
|
||||
disabled={disabled || isSaving}
|
||||
readonly={readonly}
|
||||
showSubmit={false}
|
||||
i18nNamespace={configNamespace}
|
||||
customValidate={customValidate}
|
||||
formContext={{
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
hasChanges,
|
||||
extraHasChanges,
|
||||
setExtraHasChanges,
|
||||
overrides: uiOverrides as JsonValue | undefined,
|
||||
formData: currentFormData as ConfigSectionData,
|
||||
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||
// For widgets that need access to full camera config (e.g., zone names)
|
||||
fullCameraConfig:
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
fullConfig: config,
|
||||
// When rendering camera-level sections, provide the section path so
|
||||
// field templates can look up keys under the `config/cameras` namespace
|
||||
// When using a consolidated global namespace, keys are nested
|
||||
// under the section name (e.g., `audio.label`) so provide the
|
||||
// section prefix to templates so they can attempt `${section}.${field}` lookups.
|
||||
sectionI18nPrefix: sectionPath,
|
||||
t,
|
||||
renderers: wrappedRenderers,
|
||||
sectionDocs: sectionConfig.sectionDocs,
|
||||
fieldDocs: sectionConfig.fieldDocs,
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
restartRequired: sectionConfig.restartRequired,
|
||||
requiresRestart,
|
||||
isProfile: !!profileName,
|
||||
}}
|
||||
/>
|
||||
<FieldMessagesContext.Provider value={activeFieldMessages}>
|
||||
<ConfigForm
|
||||
key={formKey}
|
||||
schema={modifiedSchema}
|
||||
formData={currentFormData}
|
||||
onChange={handleChange}
|
||||
onValidationChange={setHasValidationErrors}
|
||||
fieldOrder={sectionConfig.fieldOrder}
|
||||
fieldGroups={sectionConfig.fieldGroups}
|
||||
hiddenFields={effectiveHiddenFields}
|
||||
advancedFields={sectionConfig.advancedFields}
|
||||
liveValidate={sectionConfig.liveValidate}
|
||||
uiSchema={sectionConfig.uiSchema}
|
||||
disabled={disabled || isSaving}
|
||||
readonly={readonly}
|
||||
showSubmit={false}
|
||||
i18nNamespace={configNamespace}
|
||||
customValidate={customValidate}
|
||||
formContext={{
|
||||
level: effectiveLevel,
|
||||
cameraName,
|
||||
globalValue,
|
||||
cameraValue,
|
||||
hasChanges,
|
||||
extraHasChanges,
|
||||
setExtraHasChanges,
|
||||
overrides: uiOverrides as JsonValue | undefined,
|
||||
formData: currentFormData as ConfigSectionData,
|
||||
baselineFormData: effectiveBaselineFormData as ConfigSectionData,
|
||||
pendingDataBySection,
|
||||
onPendingDataChange,
|
||||
onFormDataChange: (data: ConfigSectionData) => handleChange(data),
|
||||
// For widgets that need access to full camera config (e.g., zone names)
|
||||
fullCameraConfig:
|
||||
effectiveLevel === "camera" && cameraName
|
||||
? config?.cameras?.[cameraName]
|
||||
: undefined,
|
||||
fullConfig: config,
|
||||
// When rendering camera-level sections, provide the section path so
|
||||
// field templates can look up keys under the `config/cameras` namespace
|
||||
// When using a consolidated global namespace, keys are nested
|
||||
// under the section name (e.g., `audio.label`) so provide the
|
||||
// section prefix to templates so they can attempt `${section}.${field}` lookups.
|
||||
sectionI18nPrefix: sectionPath,
|
||||
t,
|
||||
renderers: wrappedRenderers,
|
||||
sectionDocs: sectionConfig.sectionDocs,
|
||||
fieldDocs: sectionConfig.fieldDocs,
|
||||
hiddenFields: effectiveHiddenFields,
|
||||
restartRequired: sectionConfig.restartRequired,
|
||||
requiresRestart,
|
||||
isProfile: !!profileName,
|
||||
}}
|
||||
/>
|
||||
</FieldMessagesContext.Provider>
|
||||
|
||||
{!embedded && (
|
||||
<div
|
||||
|
||||
@@ -5,8 +5,9 @@ import {
|
||||
getUiOptions,
|
||||
ADDITIONAL_PROPERTY_FLAG,
|
||||
} from "@rjsf/utils";
|
||||
import { ComponentType, ReactNode } from "react";
|
||||
import { ComponentType, ReactNode, useContext } from "react";
|
||||
import { isValidElement } from "react";
|
||||
import { FieldMessagesContext } from "../../FieldMessagesContext";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTranslation } from "react-i18next";
|
||||
@@ -95,6 +96,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
"views/settings",
|
||||
]);
|
||||
const { getLocaleDocUrl } = useDocDomain();
|
||||
const allFieldMessages = useContext(FieldMessagesContext);
|
||||
|
||||
if (hidden) {
|
||||
return <div className="hidden">{children}</div>;
|
||||
@@ -384,21 +386,15 @@ export function FieldTemplate(props: FieldTemplateProps) {
|
||||
const beforeContent = renderCustom(beforeSpec);
|
||||
const afterContent = renderCustom(afterSpec);
|
||||
|
||||
// Render conditional field messages from ui:messages
|
||||
const fieldMessageSpecs = uiSchema?.["ui:messages"] as
|
||||
| Array<{
|
||||
key: string;
|
||||
messageKey: string;
|
||||
severity: string;
|
||||
position?: string;
|
||||
}>
|
||||
| undefined;
|
||||
const beforeMessages = fieldMessageSpecs?.filter(
|
||||
// Read field-level conditional messages from FieldMessagesContext
|
||||
const fieldPathStr = pathSegments.join(".");
|
||||
const fieldMessageSpecs = allFieldMessages.filter(
|
||||
(m) => m.field === fieldPathStr,
|
||||
);
|
||||
const beforeMessages = fieldMessageSpecs.filter(
|
||||
(m) => (m.position ?? "before") === "before",
|
||||
);
|
||||
const afterMessages = fieldMessageSpecs?.filter(
|
||||
(m) => m.position === "after",
|
||||
);
|
||||
const afterMessages = fieldMessageSpecs.filter((m) => m.position === "after");
|
||||
const beforeMessagesContent =
|
||||
beforeMessages && beforeMessages.length > 0 ? (
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -36,7 +36,15 @@ import axios from "axios";
|
||||
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
||||
import RestartDialog from "@/components/overlay/dialog/RestartDialog";
|
||||
import RestartRequiredIndicator from "@/components/indicators/RestartRequiredIndicator";
|
||||
import TextEntryDialog from "@/components/overlay/dialog/TextEntryDialog";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
@@ -53,6 +61,17 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { z } from "zod";
|
||||
|
||||
const REORDER_SAVED_INDICATOR_MS = 1500;
|
||||
|
||||
@@ -482,7 +501,7 @@ function EnabledCameraRow({
|
||||
<LuGripVertical className="size-4" />
|
||||
</button>
|
||||
<CameraNameLabel camera={camera} />
|
||||
<CameraFriendlyNameEditor
|
||||
<CameraDetailsEditor
|
||||
cameraName={camera}
|
||||
onConfigChanged={onConfigChanged}
|
||||
/>
|
||||
@@ -519,25 +538,91 @@ function CameraEnableSwitch({ cameraName }: CameraEnableSwitchProps) {
|
||||
);
|
||||
}
|
||||
|
||||
type CameraFriendlyNameEditorProps = {
|
||||
type CameraDetailsEditorProps = {
|
||||
cameraName: string;
|
||||
onConfigChanged: () => Promise<unknown>;
|
||||
};
|
||||
|
||||
function CameraFriendlyNameEditor({
|
||||
type CameraDetailsFormValues = {
|
||||
friendlyName: string;
|
||||
webuiUrl: string;
|
||||
};
|
||||
|
||||
function CameraDetailsEditor({
|
||||
cameraName,
|
||||
onConfigChanged,
|
||||
}: CameraFriendlyNameEditorProps) {
|
||||
}: CameraDetailsEditorProps) {
|
||||
const { t } = useTranslation(["views/settings", "common"]);
|
||||
const { data: config } = useSWR<FrigateConfig>("config");
|
||||
const [open, setOpen] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
|
||||
const currentFriendlyName = config?.cameras?.[cameraName]?.friendly_name;
|
||||
const currentWebuiUrl = config?.cameras?.[cameraName]?.webui_url;
|
||||
|
||||
const onSave = useCallback(
|
||||
async (text: string) => {
|
||||
const formSchema = useMemo(
|
||||
() =>
|
||||
z.object({
|
||||
friendlyName: z.string(),
|
||||
webuiUrl: z.string().refine(
|
||||
(val) => {
|
||||
const trimmed = val.trim();
|
||||
if (!trimmed) return true;
|
||||
try {
|
||||
new URL(trimmed);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{
|
||||
message: t("cameraManagement.streams.details.webuiUrlInvalid", {
|
||||
ns: "views/settings",
|
||||
}),
|
||||
},
|
||||
),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const form = useForm<CameraDetailsFormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
friendlyName: currentFriendlyName ?? "",
|
||||
webuiUrl: currentWebuiUrl ?? "",
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form values from config whenever the dialog is opened.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
form.reset({
|
||||
friendlyName: currentFriendlyName ?? "",
|
||||
webuiUrl: currentWebuiUrl ?? "",
|
||||
});
|
||||
}
|
||||
}, [open, currentFriendlyName, currentWebuiUrl, form]);
|
||||
|
||||
const onSubmit = useCallback(
|
||||
async (values: CameraDetailsFormValues) => {
|
||||
if (isSaving) return;
|
||||
|
||||
// only send fields the user actually changed
|
||||
const newFriendly = values.friendlyName.trim() || null;
|
||||
const newWebui = values.webuiUrl.trim() || null;
|
||||
const cameraUpdate: Record<string, string | null> = {};
|
||||
if (newFriendly !== (currentFriendlyName ?? null)) {
|
||||
cameraUpdate.friendly_name = newFriendly;
|
||||
}
|
||||
if (newWebui !== (currentWebuiUrl ?? null)) {
|
||||
cameraUpdate.webui_url = newWebui;
|
||||
}
|
||||
|
||||
if (Object.keys(cameraUpdate).length === 0) {
|
||||
setOpen(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
|
||||
try {
|
||||
@@ -545,9 +630,7 @@ function CameraFriendlyNameEditor({
|
||||
requires_restart: 0,
|
||||
config_data: {
|
||||
cameras: {
|
||||
[cameraName]: {
|
||||
friendly_name: text.trim() || null,
|
||||
},
|
||||
[cameraName]: cameraUpdate,
|
||||
},
|
||||
},
|
||||
});
|
||||
@@ -573,10 +656,17 @@ function CameraFriendlyNameEditor({
|
||||
setIsSaving(false);
|
||||
}
|
||||
},
|
||||
[cameraName, isSaving, onConfigChanged, t],
|
||||
[
|
||||
cameraName,
|
||||
currentFriendlyName,
|
||||
currentWebuiUrl,
|
||||
isSaving,
|
||||
onConfigChanged,
|
||||
t,
|
||||
],
|
||||
);
|
||||
|
||||
const renameLabel = t("cameraManagement.streams.friendlyName.rename", {
|
||||
const editLabel = t("cameraManagement.streams.details.edit", {
|
||||
ns: "views/settings",
|
||||
});
|
||||
|
||||
@@ -588,30 +678,107 @@ function CameraFriendlyNameEditor({
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-7"
|
||||
aria-label={renameLabel}
|
||||
aria-label={editLabel}
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<LuPencil className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>{renameLabel}</TooltipContent>
|
||||
<TooltipContent>{editLabel}</TooltipContent>
|
||||
</Tooltip>
|
||||
<TextEntryDialog
|
||||
open={open}
|
||||
setOpen={setOpen}
|
||||
title={t("cameraManagement.streams.friendlyName.title", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
description={t("cameraManagement.streams.friendlyName.description", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
defaultValue={currentFriendlyName ?? ""}
|
||||
placeholder={currentFriendlyName ? undefined : cameraName}
|
||||
allowEmpty
|
||||
isSaving={isSaving}
|
||||
onSave={onSave}
|
||||
/>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{t("cameraManagement.streams.details.title", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t("cameraManagement.streams.details.description", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="friendlyName"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("cameraManagement.streams.details.friendlyNameLabel", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder={cameraName}
|
||||
disabled={isSaving}
|
||||
autoFocus
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("cameraManagement.streams.details.friendlyNameHelp", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="webuiUrl"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
{t("cameraManagement.streams.details.webuiUrlLabel", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
{...field}
|
||||
placeholder="https://"
|
||||
disabled={isSaving}
|
||||
/>
|
||||
</FormControl>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("cameraManagement.streams.details.webuiUrlHelp", {
|
||||
ns: "views/settings",
|
||||
})}
|
||||
</p>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<DialogFooter className="pt-2">
|
||||
<Button
|
||||
type="button"
|
||||
disabled={isSaving}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
{t("button.cancel", { ns: "common" })}
|
||||
</Button>
|
||||
<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", { ns: "common" })}</span>
|
||||
</div>
|
||||
) : (
|
||||
t("button.save", { ns: "common" })
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user