add docs links, readonly keys, and restart required per field

This commit is contained in:
Josh Hawkins 2026-02-02 09:20:49 -06:00
parent edc980ab8b
commit 59b0f3a680
37 changed files with 236 additions and 35 deletions

View File

@ -1286,6 +1286,7 @@
},
"toast": {
"success": "Settings saved successfully",
"successRestartRequired": "Settings saved successfully. Restart Frigate to apply your changes.",
"error": "Failed to save settings",
"validationError": "Validation failed: {{message}}",
"resetSuccess": "Reset to global defaults",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const audio: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/audio_detectors",
restartRequired: [],
fieldOrder: [
"enabled",
"listen",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const audioTranscription: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/audio_detectors#audio-transcription",
restartRequired: [],
fieldOrder: ["enabled", "language", "device", "model_size", "live_enabled"],
hiddenFields: ["enabled_in_config"],
advancedFields: ["language", "device", "model_size"],

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const auth: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/authentication",
restartRequired: [],
fieldOrder: [
"enabled",
"reset_admin_password",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const birdseye: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/birdseye",
restartRequired: [],
fieldOrder: ["enabled", "mode", "order"],
hiddenFields: [],
advancedFields: [],

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const classification: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/custom_classification/object_classification",
restartRequired: [],
hiddenFields: ["custom"],
advancedFields: [],
},

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const database: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced#database",
restartRequired: [],
fieldOrder: ["path"],
advancedFields: [],
},

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const detect: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/camera_specific",
restartRequired: [],
fieldOrder: [
"enabled",
"fps",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const detectors: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_detectors",
restartRequired: [],
fieldOrder: [],
advancedFields: [],
hiddenFields: [

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const environmentVars: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced#environment_vars",
restartRequired: [],
fieldOrder: [],
advancedFields: [],
},

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const faceRecognition: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/face_recognition",
restartRequired: [],
fieldOrder: ["enabled", "min_area"],
hiddenFields: [],
advancedFields: ["min_area"],

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const ffmpeg: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/ffmpeg_presets",
restartRequired: [],
fieldOrder: [
"inputs",
"path",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const genai: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/genai/config",
restartRequired: [],
fieldOrder: [
"provider",
"api_key",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const live: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/live",
restartRequired: [],
fieldOrder: ["stream_name", "height", "quality"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const logger: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/advanced#logger",
restartRequired: [],
fieldOrder: ["default", "logs"],
advancedFields: ["logs"],
},

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const lpr: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/license_plate_recognition",
restartRequired: [],
fieldOrder: ["enabled", "expire_time", "min_area", "enhancement"],
hiddenFields: [],
advancedFields: ["expire_time", "min_area", "enhancement"],

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const model: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_detectors#model",
restartRequired: [],
fieldOrder: [
"path",
"labelmap_path",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const motion: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/motion_detection",
restartRequired: [],
fieldOrder: [
"enabled",
"threshold",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const mqtt: SectionConfigOverrides = {
base: {
sectionDocs: "/integrations/mqtt",
restartRequired: [],
fieldOrder: [
"enabled",
"timestamp",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const networking: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/reference",
restartRequired: [],
fieldOrder: [],
advancedFields: [],
},

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const notifications: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/notifications",
restartRequired: [],
fieldOrder: ["enabled", "email"],
fieldGroups: {},
hiddenFields: ["enabled_in_config"],

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const objects: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/object_filters",
restartRequired: [],
fieldOrder: ["track", "alert", "detect", "filters"],
fieldGroups: {
tracking: ["track", "alert", "detect"],
@ -14,6 +16,8 @@ const objects: SectionConfigOverrides = {
"genai.enabled_in_config",
"filters.*.mask",
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
],
advancedFields: ["filters"],
uiSchema: {
@ -22,6 +26,11 @@ const objects: SectionConfigOverrides = {
suppressMultiSchema: true,
},
},
"filters.*": {
"ui:options": {
additionalPropertyKeyReadonly: true,
},
},
"filters.*.max_area": {
"ui:options": {
suppressMultiSchema: true,
@ -56,7 +65,17 @@ const objects: SectionConfigOverrides = {
},
},
global: {
hiddenFields: ["genai.required_zones"],
hiddenFields: [
"enabled_in_config",
"mask",
"raw_mask",
"genai.enabled_in_config",
"filters.*.mask",
"filters.*.raw_mask",
"filters.mask",
"filters.raw_mask",
"genai.required_zones",
],
},
};

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const onvif: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/cameras#setting-up-camera-ptz-controls",
restartRequired: [],
fieldOrder: [
"host",
"port",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const proxy: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/authentication#proxy",
restartRequired: [],
fieldOrder: [
"header_map",
"logout_url",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const record: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/record",
restartRequired: [],
fieldOrder: [
"enabled",
"expire_interval",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const review: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/review",
restartRequired: [],
fieldOrder: ["alerts", "detections", "genai"],
fieldGroups: {},
hiddenFields: [

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const semanticSearch: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/semantic_search",
restartRequired: [],
fieldOrder: ["triggers"],
hiddenFields: [],
advancedFields: [],

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const snapshots: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/snapshots",
restartRequired: [],
fieldOrder: [
"enabled",
"bounding_box",

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const telemetry: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/reference",
restartRequired: [],
fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: [],
},

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const timestampStyle: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/reference",
restartRequired: [],
fieldOrder: ["position", "format", "color", "thickness"],
hiddenFields: ["effect", "enabled_in_config"],
advancedFields: [],

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const tls: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/tls",
restartRequired: [],
fieldOrder: ["enabled", "cert", "key"],
advancedFields: [],
},

View File

@ -2,6 +2,8 @@ import type { SectionConfigOverrides } from "./types";
const ui: SectionConfigOverrides = {
base: {
sectionDocs: "/configuration/reference",
restartRequired: [],
fieldOrder: ["dashboard", "order"],
hiddenFields: [],
advancedFields: [],

View File

@ -61,6 +61,12 @@ export interface SectionConfig {
advancedFields?: string[];
/** Fields to compare for override detection */
overrideFields?: string[];
/** Documentation link for the section */
sectionDocs?: string;
/** Per-field documentation links */
fieldDocs?: Record<string, string>;
/** Fields that require restart when modified (empty means all fields) */
restartRequired?: string[];
/** Whether to enable live validation */
liveValidate?: boolean;
/** Additional uiSchema overrides */
@ -402,6 +408,27 @@ export function ConfigSection({
],
);
const requiresRestartForOverrides = useCallback(
(overrides: unknown) => {
if (sectionConfig.restartRequired === undefined) {
return requiresRestart;
}
if (sectionConfig.restartRequired.length === 0) {
return true;
}
if (!overrides || typeof overrides !== "object") {
return false;
}
return sectionConfig.restartRequired.some(
(path) => get(overrides as JsonObject, path) !== undefined,
);
},
[requiresRestart, sectionConfig.restartRequired],
);
const handleReset = useCallback(() => {
isResettingRef.current = true;
setPendingData(null);
@ -426,8 +453,10 @@ export function ConfigSection({
return;
}
const needsRestart = requiresRestartForOverrides(overrides);
await axios.put("config/sett", {
requires_restart: requiresRestart ? 0 : 1,
requires_restart: needsRestart ? 1 : 0,
update_topic: updateTopic,
config_data: {
[basePath]: overrides,
@ -438,13 +467,15 @@ export function ConfigSection({
console.log("Saved config data:", {
[basePath]: overrides,
update_topic: updateTopic,
requires_restart: requiresRestart ? 0 : 1,
requires_restart: needsRestart ? 1 : 0,
});
toast.success(
t("toast.success", {
t(needsRestart ? "toast.successRestartRequired" : "toast.success", {
ns: "views/settings",
defaultValue: "Settings saved successfully",
defaultValue: needsRestart
? "Settings saved successfully. Restart Frigate to apply your changes."
: "Settings saved successfully",
}),
);
@ -494,7 +525,6 @@ export function ConfigSection({
pendingData,
level,
cameraName,
requiresRestart,
t,
refreshConfig,
onSave,
@ -504,6 +534,7 @@ export function ConfigSection({
schemaDefaults,
updateTopic,
setPendingData,
requiresRestartForOverrides,
]);
// Handle reset to global/defaults - removes camera-level override or resets global to defaults
@ -662,6 +693,8 @@ export function ConfigSection({
t,
renderers:
sectionConfig?.renderers ?? sectionRenderers?.[sectionPath],
sectionDocs: sectionConfig.sectionDocs,
fieldDocs: sectionConfig.fieldDocs,
}}
/>

View File

@ -13,6 +13,9 @@ import { useTranslation } from "react-i18next";
import { isNullableUnionSchema } from "../fields/nullableUtils";
import { getTranslatedLabel } from "@/utils/i18n";
import { ConfigFormContext } from "@/types/configForm";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
import { useDocDomain } from "@/hooks/use-doc-domain";
/**
* Build the i18n translation key path for nested fields using the field path
@ -114,6 +117,7 @@ export function FieldTemplate(props: FieldTemplateProps) {
i18nNamespace || "common",
"views/settings",
]);
const { getLocaleDocUrl } = useDocDomain();
if (hidden) {
return <div className="hidden">{children}</div>;
@ -148,6 +152,13 @@ export function FieldTemplate(props: FieldTemplateProps) {
const translatedFilterObjectLabel = filterObjectLabel
? getTranslatedLabel(filterObjectLabel, "object")
: undefined;
const fieldDocsKey = translationPath || pathSegments.join(".");
const fieldDocsPath = fieldDocsKey
? formContext?.fieldDocs?.[fieldDocsKey]
: undefined;
const fieldDocsUrl = fieldDocsPath
? getLocaleDocUrl(fieldDocsPath)
: undefined;
// Use schema title/description as primary source (from JSON Schema)
const schemaTitle = schema.title;
@ -394,6 +405,19 @@ export function FieldTemplate(props: FieldTemplateProps) {
{finalDescription}
</p>
)}
{fieldDocsUrl && !isMultiSchemaWrapper && !isObjectField && (
<div className="flex items-center text-xs text-primary">
<Link
to={fieldDocsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
)}
</div>
<div className="flex items-center gap-2">{children}</div>
</div>
@ -406,6 +430,19 @@ export function FieldTemplate(props: FieldTemplateProps) {
{finalDescription}
</p>
)}
{fieldDocsUrl && !isMultiSchemaWrapper && !isObjectField && (
<div className="flex items-center text-xs text-primary">
<Link
to={fieldDocsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
)}
</>
)}

View File

@ -1,6 +1,7 @@
import {
ADDITIONAL_PROPERTY_FLAG,
FormContextType,
getUiOptions,
RJSFSchema,
StrictRJSFSchema,
WrapIfAdditionalTemplateProps,
@ -30,6 +31,7 @@ export function WrapIfAdditionalTemplate<
readonly,
required,
schema,
uiSchema,
} = props;
const { t } = useTranslation(["views/settings"]);
@ -57,41 +59,63 @@ export function WrapIfAdditionalTemplate<
const removeLabel = t("configForm.additionalProperties.remove", {
ns: "views/settings",
});
const uiOptions = getUiOptions(uiSchema);
const keyIsReadonly = uiOptions.additionalPropertyKeyReadonly === true;
return (
<div
className={cn("grid grid-cols-12 items-start gap-2", classNames)}
style={style}
>
<div className="col-span-12 space-y-2 md:col-span-1">
{displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>}
<Input
id={keyId}
name={keyId}
required={required}
defaultValue={label}
placeholder={keyPlaceholder}
disabled={disabled || readonly}
onBlur={!readonly ? onKeyRenameBlur : undefined}
/>
</div>
<div className="col-span-12 space-y-2 md:col-span-10">
{displayLabel && <Label htmlFor={id}>{valueLabel}</Label>}
{!keyIsReadonly && (
<div className="col-span-12 space-y-2 md:col-span-1">
{displayLabel && <Label htmlFor={keyId}>{keyLabel}</Label>}
{keyIsReadonly ? (
<div
id={keyId}
className="flex items-center text-sm text-muted-foreground"
>
{label}
</div>
) : (
<Input
id={keyId}
name={keyId}
required={required}
defaultValue={label}
placeholder={keyPlaceholder}
disabled={disabled || readonly}
onBlur={!readonly ? onKeyRenameBlur : undefined}
/>
)}
</div>
)}
<div
className={cn(
"col-span-12 space-y-2",
!keyIsReadonly && "md:col-span-10",
)}
>
{!keyIsReadonly && displayLabel && (
<Label htmlFor={id}>{valueLabel}</Label>
)}
<div className="min-w-0">{children}</div>
</div>
<div className="col-span-12 flex items-center md:col-span-1 md:justify-center">
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemoveProperty}
disabled={disabled || readonly}
aria-label={removeLabel}
title={removeLabel}
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
{!keyIsReadonly && (
<div className="col-span-12 flex items-center md:col-span-1 md:justify-center">
<Button
type="button"
variant="ghost"
size="icon"
onClick={onRemoveProperty}
disabled={disabled || readonly}
aria-label={removeLabel}
title={removeLabel}
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
)}
</div>
);
}

View File

@ -22,6 +22,8 @@ export type ConfigFormContext = {
fullConfig?: FrigateConfig;
i18nNamespace?: string;
sectionI18nPrefix?: string;
sectionDocs?: string;
fieldDocs?: Record<string, string>;
t?: (key: string, options?: Record<string, unknown>) => string;
renderers?: Record<string, RendererComponent>;
};

View File

@ -1,10 +1,14 @@
import { useCallback, useState } from "react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import type { SectionConfig } from "@/components/config-form/sections";
import { ConfigSectionTemplate } from "@/components/config-form/sections";
import type { PolygonType } from "@/types/canvas";
import { Badge } from "@/components/ui/badge";
import type { ConfigSectionData } from "@/types/configForm";
import { getSectionConfig } from "@/utils/sectionConfigsUtils";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { Link } from "react-router-dom";
import { LuExternalLink } from "react-icons/lu";
export type SettingsPageProps = {
selectedCamera?: string;
@ -58,10 +62,18 @@ export function SingleSectionPage({
"views/settings",
"common",
]);
const { getLocaleDocUrl } = useDocDomain();
const [sectionStatus, setSectionStatus] = useState<SectionStatus>({
hasChanges: false,
isOverridden: false,
});
const resolvedSectionConfig = useMemo(
() => sectionConfig ?? getSectionConfig(sectionKey, level),
[level, sectionConfig, sectionKey],
);
const sectionDocsUrl = resolvedSectionConfig.sectionDocs
? getLocaleDocUrl(resolvedSectionConfig.sectionDocs)
: undefined;
const handleSectionStatusChange = useCallback(
(status: SectionStatus) => {
@ -93,6 +105,19 @@ export function SingleSectionPage({
{t(`${sectionKey}.description`, { ns: sectionNamespace })}
</div>
)}
{sectionDocsUrl && (
<div className="flex items-center text-sm text-primary">
<Link
to={sectionDocsUrl}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
)}
</div>
<div className="flex flex-col items-end gap-2 md:flex-row md:items-center">
<div className="flex flex-wrap items-center justify-end gap-2">
@ -127,7 +152,7 @@ export function SingleSectionPage({
showOverrideIndicator={showOverrideIndicator}
onSave={() => setUnsavedChanges?.(false)}
showTitle={false}
sectionConfig={sectionConfig}
sectionConfig={resolvedSectionConfig}
pendingDataBySection={pendingDataBySection}
onPendingDataChange={onPendingDataChange}
requiresRestart={requiresRestart}