Full UI configuration (#22151)

* use react-jsonschema-form for UI config

* don't use properties wrapper when generating config i18n json

* configure for full i18n support

* section fields

* add descriptions to all fields for i18n

* motion i18n

* fix nullable fields

* sanitize internal fields

* add switches widgets and use friendly names

* fix nullable schema entries

* ensure update_topic is added to api calls

this needs further backend implementation to work correctly

* add global sections, camera config overrides, and reset button

* i18n

* add reset logic to global config view

* tweaks

* fix sections and live validation

* fix validation for schema objects that can be null

* generic and custom per-field validation

* improve generic error validation messages

* remove show advanced fields switch

* tweaks

* use shadcn theme

* fix array field template

* i18n tweaks

* remove collapsible around root section

* deep merge schema for advanced fields

* add array field item template and fix ffmpeg section

* add missing i18n keys

* tweaks

* comment out api call for testing

* add config groups as a separate i18n namespace

* add descriptions to all pydantic fields

* make titles more concise

* new titles as i18n

* update i18n config generation script to use json schema

* tweaks

* tweaks

* rebase

* clean up

* form tweaks

* add wildcards and fix object filter fields

* add field template for additionalproperties schema objects

* improve typing

* add section description from schema and clarify global vs camera level descriptions

* separate and consolidate global and camera i18n namespaces

* clean up now obsolete namespaces

* tweaks

* refactor sections and overrides

* add ability to render components before and after fields

* fix titles

* chore(sections): remove legacy single-section components replaced by template

* refactor configs to use individual files with a template

* fix review description

* apply hidden fields after ui schema

* move util

* remove unused i18n

* clean up error messages

* fix fast refresh

* add custom validation and use it for ffmpeg input roles

* update nav tree

* remove unused

* re-add override and modified indicators

* mark pending changes and add confirmation dialog for resets

* fix red unsaved dot

* tweaks

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

* add special case and comments for global motion section

* add section form special cases

* combine review sections

* tweaks

* add audio labels endpoint

* add audio label switches and input to filter list

* fix type

* remove key from config when resetting to default/global

* don't show description for new key/val fields

* tweaks

* spacing tweaks

* add activity indicator and scrollbar tweaks

* add docs to filter fields

* wording changes

* fix global ffmpeg section

* add review classification zones to review form

* add backend endpoint and frontend widget for ffmpeg presets and manual args

* improve wording

* hide descriptions for additional properties arrays

* add warning log about incorrectly nested model config

* spacing and language tweaks

* fix i18n keys

* networking section docs and description

* small wording tweaks

* add layout grid field

* refactor with shared utilities

* field order

* add individual detectors to schema

add detector titles and descriptions (docstrings in pydantic are used for descriptions) and add i18n keys to globals

* clean up detectors section and i18n

* don't save model config back to yaml when saving detectors

* add full detectors config to api model dump

works around the way we use detector plugins so we can have the full detector config for the frontend

* add restart button to toast when restart is required

* add ui option to remove inner cards

* fix buttons

* section tweaks

* don't zoom into text on mobile

* make buttons sticky at bottom of sections

* small tweaks

* highlight label of changed fields

* add null to enum list when unwrapping

* refactor to shared utils and add save all button

* add undo all button

* add RJSF to dictionary

* consolidate utils

* preserve form data when changing cameras

* add mono fonts

* add popover to show what fields will be saved

* fix mobile menu not re-rendering with unsaved dots

* tweaks

* fix logger and env vars config section saving

use escaped periods in keys to retain them in the config file (eg "frigate.embeddings")

* add timezone widget

* role map field with validation

* fix validation for model section

* add another hidden field

* add footer message for required restart

* use rjsf for notifications view

* fix config saving

* add replace rules field

* default column layout and add field sizing

* clean up field template

* refactor profile settings to match rjsf forms

* tweaks

* refactor frigate+ view and make tweaks to sections

* show frigate+ model info in detection model settings when using a frigate+ model

* update restartRequired for all fields

* fix restart fields

* tweaks and add ability enable disabled cameras

more backend changes required

* require restart when enabling camera that is disabled in config

* disable save when form is invalid

* refactor ffmpeg section for readability

* change label

* clean up camera inputs fields

* misc tweaks to ffmpeg section

- add raw paths endpoint to ensure credentials get saved
- restart required tooltip

* maintenance settings tweaks

* don't mutate with lodash

* fix description re-rendering for nullable object fields

* hide reindex field

* update rjsf

* add frigate+ description to settings pane

* disable save all when any section is invalid

* show translated field name in validation error pane

* clean up

* remove unused

* fix genai merge

* fix genai
This commit is contained in:
Josh Hawkins
2026-02-27 08:55:36 -07:00
committed by GitHub
parent eeefbf2bb5
commit e7250f24cb
206 changed files with 22200 additions and 4435 deletions
@@ -0,0 +1,403 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import { Trans, useTranslation } from "react-i18next";
import cloneDeep from "lodash/cloneDeep";
import get from "lodash/get";
import isEqual from "lodash/isEqual";
import set from "lodash/set";
import { LuExternalLink } from "react-icons/lu";
import { MdCircle } from "react-icons/md";
import Heading from "@/components/ui/heading";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { cn } from "@/lib/utils";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { useCameraFriendlyName } from "@/hooks/use-camera-friendly-name";
import { resolveZoneName } from "@/hooks/use-zone-friendly-name";
import { getTranslatedLabel } from "@/utils/i18n";
import { formatList } from "@/utils/stringUtil";
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import type { SectionRendererProps } from "./registry";
const EMPTY_ZONES: string[] = [];
function getRequiredZones(
formData: JsonObject | undefined,
path: string,
): string[] {
const value = get(formData, path);
return Array.isArray(value) ? (value as string[]) : EMPTY_ZONES;
}
export default function CameraReviewClassification({
formContext,
selectedCamera,
}: SectionRendererProps) {
const { t } = useTranslation(["views/settings", "common"]);
const { getLocaleDocUrl } = useDocDomain();
const cameraName = formContext?.cameraName ?? selectedCamera;
const fullFormData = formContext?.formData as JsonObject | undefined;
const baselineFormData = formContext?.baselineFormData as
| JsonObject
| undefined;
const cameraConfig = formContext?.fullCameraConfig;
const alertsZones = useMemo(
() => getRequiredZones(fullFormData, "alerts.required_zones"),
[fullFormData],
);
const detectionsZones = useMemo(
() => getRequiredZones(fullFormData, "detections.required_zones"),
[fullFormData],
);
// Track whether zones have been modified from baseline for label coloring
const alertsZonesModified = useMemo(() => {
if (!baselineFormData) return false;
const baseline = getRequiredZones(
baselineFormData,
"alerts.required_zones",
);
return !isEqual(alertsZones, baseline);
}, [alertsZones, baselineFormData]);
const detectionsZonesModified = useMemo(() => {
if (!baselineFormData) return false;
const baseline = getRequiredZones(
baselineFormData,
"detections.required_zones",
);
return !isEqual(detectionsZones, baseline);
}, [detectionsZones, baselineFormData]);
const [selectDetections, setSelectDetections] = useState(
detectionsZones.length > 0,
);
const previousCameraRef = useRef(cameraName);
const isSynced = formContext?.hasChanges === false;
useEffect(() => {
const cameraChanged = previousCameraRef.current !== cameraName;
if (cameraChanged) {
previousCameraRef.current = cameraName;
}
if (cameraChanged || isSynced) {
setSelectDetections(detectionsZones.length > 0);
}
}, [cameraName, detectionsZones.length, isSynced]);
const zones = useMemo(() => {
if (!cameraConfig) {
return undefined;
}
return Object.entries(cameraConfig.zones).map(([name, zoneData]) => {
const zone =
zoneData as (typeof cameraConfig.zones)[keyof typeof cameraConfig.zones];
return {
camera: cameraConfig.name,
name,
friendly_name: cameraConfig.zones[name].friendly_name,
objects: zone.objects,
color: zone.color,
};
});
}, [cameraConfig]);
const alertsLabels = useMemo(() => {
return cameraConfig?.review.alerts.labels
? formatList(
cameraConfig.review.alerts.labels.map((label: string) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
: "";
}, [cameraConfig]);
const detectionsLabels = useMemo(() => {
return cameraConfig?.review.detections.labels
? formatList(
cameraConfig.review.detections.labels.map((label: string) =>
getTranslatedLabel(
label,
cameraConfig?.audio?.listen?.includes(label) ? "audio" : "object",
),
),
)
: "";
}, [cameraConfig]);
const selectCameraName = useCameraFriendlyName(cameraName);
const getZoneName = useCallback(
(zoneId: string, camId?: string) =>
resolveZoneName(formContext?.fullConfig, zoneId, camId),
[formContext?.fullConfig],
);
const updateFormData = useCallback(
(path: string, nextValue: string[]) => {
if (!formContext?.onFormDataChange || !fullFormData) {
return;
}
const nextData = cloneDeep(fullFormData) as JsonObject;
set(nextData, path, nextValue);
formContext.onFormDataChange(nextData as ConfigSectionData);
},
[formContext, fullFormData],
);
const handleZoneToggle = useCallback(
(path: string, zoneName: string) => {
const currentZones = getRequiredZones(fullFormData, path);
const nextZones = currentZones.includes(zoneName)
? currentZones.filter((value) => value !== zoneName)
: [...currentZones, zoneName];
updateFormData(path, nextZones);
},
[fullFormData, updateFormData],
);
const handleDetectionsToggle = useCallback(
(checked: boolean | string) => {
const isChecked = checked === true;
if (!isChecked) {
updateFormData("detections.required_zones", []);
}
setSelectDetections(isChecked);
},
[updateFormData],
);
if (!cameraName || formContext?.level !== "camera") {
return null;
}
return (
<div className="mb-4 space-y-6">
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.reviewClassification.title
</Trans>
</Heading>
<div className="max-w-6xl">
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 text-sm text-primary-variant">
<p>
<Trans ns="views/settings">
cameraReview.reviewClassification.desc
</Trans>
</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/review")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
</div>
<div
className={cn(
"w-full max-w-5xl space-y-0",
zones && zones.length > 0 && "grid items-start gap-5 md:grid-cols-2",
)}
>
<div>
{zones && zones.length > 0 ? (
<>
<div className="mb-2">
<Label
className={cn(
"flex flex-row items-center text-base",
alertsZonesModified && "text-danger",
)}
>
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
<MdCircle className="ml-3 size-2 text-severity_alert" />
</Label>
<div className="text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.reviewClassification.selectAlertsZones
</Trans>
</div>
</div>
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
{zones.map((zone) => (
<div
key={zone.name}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={alertsZones.includes(zone.name)}
onCheckedChange={() =>
handleZoneToggle("alerts.required_zones", zone.name)
}
/>
<Label
className={cn(
"font-normal",
!zone.friendly_name && "smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</Label>
</div>
))}
</div>
</>
) : (
<div className="font-normal text-destructive">
<Trans ns="views/settings">
cameraReview.reviewClassification.noDefinedZones
</Trans>
</div>
)}
<div className="mt-2 text-sm">
{alertsZones.length > 0
? t("cameraReview.reviewClassification.zoneObjectAlertsTips", {
alertsLabels,
zone: formatList(
alertsZones.map((zone) => getZoneName(zone, cameraName)),
),
cameraName: selectCameraName,
})
: t("cameraReview.reviewClassification.objectAlertsTips", {
alertsLabels,
cameraName: selectCameraName,
})}
</div>
</div>
<div>
{zones && zones.length > 0 && (
<>
<div className="mb-2">
<Label
className={cn(
"flex flex-row items-center text-base",
detectionsZonesModified && "text-danger",
)}
>
<Trans ns="views/settings">
cameraReview.review.detections
</Trans>
<MdCircle className="ml-3 size-2 text-severity_detection" />
</Label>
{selectDetections && (
<div className="text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.reviewClassification.selectDetectionsZones
</Trans>
</div>
)}
</div>
{selectDetections && (
<div className="max-w-md rounded-lg bg-secondary p-4 md:max-w-full">
{zones.map((zone) => (
<div
key={zone.name}
className="mb-3 flex flex-row items-center space-x-3 space-y-0 last:mb-0"
>
<Checkbox
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={detectionsZones.includes(zone.name)}
onCheckedChange={() =>
handleZoneToggle(
"detections.required_zones",
zone.name,
)
}
/>
<Label
className={cn(
"font-normal",
!zone.friendly_name && "smart-capitalize",
)}
>
{zone.friendly_name || zone.name}
</Label>
</div>
))}
</div>
)}
<div className="mb-0 mt-3 flex flex-row items-center gap-2">
<Checkbox
id="select-detections"
className="size-5 text-white accent-white data-[state=checked]:bg-selected data-[state=checked]:text-white"
checked={selectDetections}
onCheckedChange={handleDetectionsToggle}
/>
<div className="grid gap-1.5 leading-none">
<label
htmlFor="select-detections"
className="text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
>
<Trans ns="views/settings">
cameraReview.reviewClassification.limitDetections
</Trans>
</label>
</div>
</div>
</>
)}
<div className="mt-2 text-sm">
{detectionsZones.length > 0 ? (
!selectDetections ? (
<Trans
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.text"
values={{
detectionsLabels,
zone: formatList(
detectionsZones.map((zone) =>
getZoneName(zone, cameraName),
),
),
cameraName: selectCameraName,
}}
ns="views/settings"
/>
) : (
<Trans
i18nKey="cameraReview.reviewClassification.zoneObjectDetectionsTips.notSelectDetections"
values={{
detectionsLabels,
zone: formatList(
detectionsZones.map((zone) =>
getZoneName(zone, cameraName),
),
),
cameraName: selectCameraName,
}}
ns="views/settings"
/>
)
) : (
<Trans
i18nKey="cameraReview.reviewClassification.objectDetectionsTips"
values={{
detectionsLabels,
cameraName: selectCameraName,
}}
ns="views/settings"
/>
)}
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,164 @@
import { useMemo } from "react";
import useSWR from "swr";
import { Trans } from "react-i18next";
import Heading from "@/components/ui/heading";
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import { Label } from "@/components/ui/label";
import { FrigateConfig } from "@/types/frigateConfig";
import {
useAlertsState,
useDetectionsState,
useObjectDescriptionState,
useReviewDescriptionState,
} from "@/api/ws";
import type { SectionRendererProps } from "./registry";
import CameraReviewClassification from "./CameraReviewClassification";
export default function CameraReviewStatusToggles({
selectedCamera,
formContext,
}: SectionRendererProps) {
const { data: config } = useSWR<FrigateConfig>("config");
const cameraId = selectedCamera ?? "";
const cameraConfig = useMemo(() => {
if (config && selectedCamera) {
return config.cameras[selectedCamera];
}
}, [config, selectedCamera]);
const { payload: alertsState, send: sendAlerts } = useAlertsState(cameraId);
const { payload: detectionsState, send: sendDetections } =
useDetectionsState(cameraId);
const { payload: objDescState, send: sendObjDesc } =
useObjectDescriptionState(cameraId);
const { payload: revDescState, send: sendRevDesc } =
useReviewDescriptionState(cameraId);
if (!selectedCamera || !cameraConfig) {
return null;
}
return (
<div className="flex flex-col">
<Heading as="h4" className="my-2">
<Trans ns="views/settings">cameraReview.title</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="alerts-enabled"
className="mr-3"
checked={alertsState == "ON"}
onCheckedChange={(isChecked) => {
sendAlerts(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="alerts-enabled">
<Trans ns="views/settings">cameraReview.review.alerts</Trans>
</Label>
</div>
</div>
<div className="flex flex-col">
<div className="flex flex-row items-center">
<Switch
id="detections-enabled"
className="mr-3"
checked={detectionsState == "ON"}
onCheckedChange={(isChecked) => {
sendDetections(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="detections-enabled">
<Trans ns="views/settings">camera.review.detections</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">cameraReview.review.desc</Trans>
</div>
</div>
</div>
{cameraConfig?.objects?.genai?.enabled_in_config && (
<>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.object_descriptions.title
</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="object-descriptions-enabled"
className="mr-3"
checked={objDescState == "ON"}
onCheckedChange={(isChecked) => {
sendObjDesc(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="object-descriptions-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.object_descriptions.desc
</Trans>
</div>
</div>
</>
)}
{cameraConfig?.review?.genai?.enabled_in_config && (
<>
<Separator className="my-2 flex bg-secondary" />
<Heading as="h4" className="my-2">
<Trans ns="views/settings">
cameraReview.review_descriptions.title
</Trans>
</Heading>
<div className="mb-5 mt-2 flex max-w-5xl flex-col gap-2 space-y-3 text-sm text-primary-variant">
<div className="flex flex-row items-center">
<Switch
id="review-descriptions-enabled"
className="mr-3"
checked={revDescState == "ON"}
onCheckedChange={(isChecked) => {
sendRevDesc(isChecked ? "ON" : "OFF");
}}
/>
<div className="space-y-0.5">
<Label htmlFor="review-descriptions-enabled">
<Trans>button.enabled</Trans>
</Label>
</div>
</div>
<div className="mt-3 text-sm text-muted-foreground">
<Trans ns="views/settings">
cameraReview.review_descriptions.desc
</Trans>
</div>
</div>
</>
)}
<CameraReviewClassification
selectedCamera={selectedCamera}
formContext={formContext}
/>
</div>
);
}
@@ -0,0 +1,843 @@
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { Button } from "@/components/ui/button";
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";
import { Toaster } from "@/components/ui/sonner";
import { StatusBarMessagesContext } from "@/context/statusbar-provider";
import { FrigateConfig } from "@/types/frigateConfig";
import { zodResolver } from "@hookform/resolvers/zod";
import axios from "axios";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { useForm } from "react-hook-form";
import { LuCheck, LuExternalLink, LuX } from "react-icons/lu";
import { CiCircleAlert } from "react-icons/ci";
import { Link } from "react-router-dom";
import { toast } from "sonner";
import useSWR from "swr";
import { z } from "zod";
import {
useNotifications,
useNotificationSuspend,
useNotificationTest,
} from "@/api/ws";
import {
Select,
SelectTrigger,
SelectValue,
SelectContent,
SelectItem,
} from "@/components/ui/select";
import { formatUnixTimestampToDateTime } from "@/utils/dateUtil";
import FilterSwitch from "@/components/filter/FilterSwitch";
import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert";
import { Trans, useTranslation } from "react-i18next";
import { useDateLocale } from "@/hooks/use-date-locale";
import { useDocDomain } from "@/hooks/use-doc-domain";
import { CameraNameLabel } from "@/components/camera/FriendlyNameLabel";
import { useIsAdmin } from "@/hooks/use-is-admin";
import { cn } from "@/lib/utils";
import cloneDeep from "lodash/cloneDeep";
import isEqual from "lodash/isEqual";
import set from "lodash/set";
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import { sanitizeSectionData } from "@/utils/configUtil";
import type { SectionRendererProps } from "./registry";
const NOTIFICATION_SERVICE_WORKER = "/notification-worker.js";
import {
SettingsGroupCard,
SPLIT_ROW_CLASS_NAME,
CONTROL_COLUMN_CLASS_NAME,
} from "@/components/card/SettingsGroupCard";
export default function NotificationsSettingsExtras({
formContext,
}: SectionRendererProps) {
const { t } = useTranslation([
"views/settings",
"common",
"components/filter",
]);
const { getLocaleDocUrl } = useDocDomain();
// roles
const isAdmin = useIsAdmin();
// status bar
const { addMessage, removeMessage } = useContext(StatusBarMessagesContext)!;
// config
const { data: config } = useSWR<FrigateConfig>("config", {
revalidateOnFocus: false,
});
const allCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order)
.filter((c) => c.enabled_in_config);
}, [config]);
const notificationCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.values(config.cameras)
.filter(
(conf) =>
conf.enabled_in_config &&
conf.notifications &&
conf.notifications.enabled_in_config,
)
.sort((aConf, bConf) => aConf.ui.order - bConf.ui.order);
}, [config]);
const { send: sendTestNotification } = useNotificationTest();
// notification state
const [registration, setRegistration] =
useState<ServiceWorkerRegistration | null>();
const [cameraSelectionTouched, setCameraSelectionTouched] = useState(false);
useEffect(() => {
if (!("Notification" in window) || !window.isSecureContext) {
return;
}
navigator.serviceWorker
.getRegistration(NOTIFICATION_SERVICE_WORKER)
.then((worker) => {
if (worker) {
setRegistration(worker);
} else {
setRegistration(null);
}
})
.catch(() => {
setRegistration(null);
});
}, []);
// form
const formSchema = z.object({
allEnabled: z.boolean(),
email: z.string(),
cameras: z.array(z.string()),
});
const pendingDataBySection = useMemo(
() => formContext?.pendingDataBySection ?? {},
[formContext?.pendingDataBySection],
);
const pendingCameraOverrides = useMemo(() => {
const overrides: Record<string, boolean> = {};
Object.entries(pendingDataBySection).forEach(([key, data]) => {
if (!key.endsWith("::notifications")) {
return;
}
const cameraName = key.slice(0, key.indexOf("::"));
const enabled = (data as JsonObject | undefined)?.enabled;
if (typeof enabled === "boolean") {
overrides[cameraName] = enabled;
}
});
return overrides;
}, [pendingDataBySection]);
const defaultValues = useMemo(() => {
const formData = formContext?.formData as JsonObject | undefined;
const enabledValue =
typeof formData?.enabled === "boolean"
? formData.enabled
: (config?.notifications.enabled ?? false);
const emailValue =
typeof formData?.email === "string"
? formData.email
: (config?.notifications.email ?? "");
const baseEnabledSet = new Set(
notificationCameras.map((camera) => camera.name),
);
const selectedCameras = enabledValue
? []
: allCameras
.filter((camera) => {
const pendingEnabled = pendingCameraOverrides[camera.name];
if (typeof pendingEnabled === "boolean") {
return pendingEnabled;
}
return baseEnabledSet.has(camera.name);
})
.map((camera) => camera.name);
return {
allEnabled: Boolean(enabledValue),
email: typeof emailValue === "string" ? emailValue : "",
cameras: selectedCameras,
};
}, [
allCameras,
config?.notifications.email,
config?.notifications.enabled,
formContext?.formData,
notificationCameras,
pendingCameraOverrides,
]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
mode: "onChange",
defaultValues,
});
const watchAllEnabled = form.watch("allEnabled");
const watchCameras = form.watch("cameras");
const watchEmail = form.watch("email");
const pendingCameraOverridesRef = useRef<Set<string>>(new Set());
const resetFormState = useCallback(
(values: z.infer<typeof formSchema>) => {
form.reset(values);
setCameraSelectionTouched(false);
pendingCameraOverridesRef.current.clear();
},
[form],
);
// pending changes sync (Undo All / Save All)
const hasPendingNotifications = useMemo(
() =>
Object.keys(pendingDataBySection).some(
(key) => key === "notifications" || key.endsWith("::notifications"),
),
[pendingDataBySection],
);
const hasPendingNotificationsRef = useRef(hasPendingNotifications);
useEffect(() => {
if (!config || form.formState.isDirty || hasPendingNotifications) {
return;
}
resetFormState(defaultValues);
}, [
config,
defaultValues,
form.formState.isDirty,
hasPendingNotifications,
resetFormState,
]);
useEffect(() => {
const hadPending = hasPendingNotificationsRef.current;
hasPendingNotificationsRef.current = hasPendingNotifications;
if (hadPending && !hasPendingNotifications) {
resetFormState(defaultValues);
}
}, [hasPendingNotifications, defaultValues, resetFormState]);
useEffect(() => {
if (!formContext?.onFormDataChange) {
return;
}
const baseData =
(formContext.formData as JsonObject | undefined) ??
(config?.notifications as JsonObject | undefined);
if (!baseData) {
return;
}
const nextData = cloneDeep(baseData);
const normalizedEmail = watchEmail?.trim() ? watchEmail : null;
set(nextData, "enabled", Boolean(watchAllEnabled));
set(nextData, "email", normalizedEmail);
formContext.onFormDataChange(nextData as ConfigSectionData);
}, [config, formContext, watchAllEnabled, watchEmail]);
// camera selection overrides
const baselineCameraSelection = useMemo(() => {
if (!config) {
return [] as string[];
}
return config.notifications.enabled
? []
: notificationCameras.map((camera) => camera.name);
}, [config, notificationCameras]);
const cameraSelectionDirty = useMemo(() => {
const current = Array.isArray(watchCameras) ? watchCameras : [];
return !isEqual([...current].sort(), [...baselineCameraSelection].sort());
}, [watchCameras, baselineCameraSelection]);
useEffect(() => {
formContext?.setExtraHasChanges?.(cameraSelectionDirty);
}, [cameraSelectionDirty, formContext]);
useEffect(() => {
const onPendingDataChange = formContext?.onPendingDataChange;
if (!onPendingDataChange || !config) {
return;
}
if (!cameraSelectionTouched) {
return;
}
if (!cameraSelectionDirty) {
pendingCameraOverridesRef.current.forEach((cameraName) => {
onPendingDataChange("notifications", cameraName, null);
});
pendingCameraOverridesRef.current.clear();
setCameraSelectionTouched(false);
return;
}
const selectedCameras = Array.isArray(watchCameras) ? watchCameras : [];
allCameras.forEach((camera) => {
const desiredEnabled = watchAllEnabled
? true
: selectedCameras.includes(camera.name);
const currentNotifications = config.cameras[camera.name]?.notifications;
const currentEnabled = currentNotifications?.enabled;
if (desiredEnabled === currentEnabled) {
if (pendingCameraOverridesRef.current.has(camera.name)) {
onPendingDataChange("notifications", camera.name, null);
pendingCameraOverridesRef.current.delete(camera.name);
}
return;
}
if (!currentNotifications) {
return;
}
const nextNotifications = cloneDeep(
currentNotifications as JsonObject,
) as JsonObject;
set(nextNotifications, "enabled", desiredEnabled);
const sanitizedNotifications = sanitizeSectionData(
nextNotifications as ConfigSectionData,
["enabled_in_config", "email"],
);
onPendingDataChange("notifications", camera.name, sanitizedNotifications);
pendingCameraOverridesRef.current.add(camera.name);
});
}, [
allCameras,
cameraSelectionDirty,
cameraSelectionTouched,
config,
formContext,
watchAllEnabled,
watchCameras,
]);
const anyCameraNotificationsEnabled = useMemo(
() =>
config &&
Object.values(config.cameras).some(
(c) =>
c.enabled_in_config &&
c.notifications &&
c.notifications.enabled_in_config,
),
[config],
);
const shouldFetchPubKey = Boolean(
config &&
(config.notifications?.enabled || anyCameraNotificationsEnabled) &&
(watchAllEnabled ||
(Array.isArray(watchCameras) && watchCameras.length > 0)),
);
const { data: publicKey } = useSWR(
shouldFetchPubKey ? "notifications/pubkey" : null,
{ revalidateOnFocus: false },
);
const subscribeToNotifications = useCallback(
(workerRegistration: ServiceWorkerRegistration) => {
if (!workerRegistration) {
return;
}
addMessage(
"notification_settings",
t("notification.unsavedRegistrations"),
undefined,
"registration",
);
workerRegistration.pushManager
.subscribe({
userVisibleOnly: true,
applicationServerKey: publicKey,
})
.then((pushSubscription) => {
axios
.post("notifications/register", {
sub: pushSubscription,
})
.catch(() => {
toast.error(t("notification.toast.error.registerFailed"), {
position: "top-center",
});
pushSubscription.unsubscribe();
workerRegistration.unregister();
setRegistration(null);
});
toast.success(t("notification.toast.success.registered"), {
position: "top-center",
});
});
},
[addMessage, publicKey, t],
);
useEffect(() => {
if (watchCameras.length > 0) {
form.setValue("allEnabled", false);
}
}, [watchCameras, allCameras, form]);
useEffect(() => {
document.title = t("documentTitle.notifications");
}, [t]);
if (formContext?.level && formContext.level !== "global") {
return null;
}
if (!config) {
return <ActivityIndicator />;
}
if (!("Notification" in window) || !window.isSecureContext) {
return (
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto pb-2 md:order-none">
<div className="w-full max-w-5xl">
<SettingsGroupCard
title={t("notification.notificationSettings.title")}
>
<div className="space-y-4">
<div className="flex flex-col gap-2 text-sm text-primary-variant">
<p>{t("notification.notificationSettings.desc")}</p>
<div className="flex items-center text-primary">
<Link
to={getLocaleDocUrl("configuration/notifications")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</div>
<Alert variant="destructive">
<CiCircleAlert className="size-5" />
<AlertTitle>
{t("notification.notificationUnavailable.title")}
</AlertTitle>
<AlertDescription>
<Trans ns="views/settings">
notification.notificationUnavailable.desc
</Trans>
<div className="mt-3 flex items-center">
<Link
to={getLocaleDocUrl("configuration/authentication")}
target="_blank"
rel="noopener noreferrer"
className="inline"
>
{t("readTheDocumentation", { ns: "common" })}{" "}
<LuExternalLink className="ml-2 inline-flex size-3" />
</Link>
</div>
</AlertDescription>
</Alert>
</div>
</SettingsGroupCard>
</div>
</div>
);
}
return (
<div className="flex size-full flex-col md:flex-row">
<Toaster position="top-center" closeButton={true} />
<div className="scrollbar-container order-last mb-2 mt-2 flex h-full w-full flex-col overflow-y-auto px-2 md:order-none">
<div className={cn("w-full max-w-5xl space-y-6")}>
{isAdmin && (
<SettingsGroupCard
title={t("notification.notificationSettings.title")}
>
<div className="space-y-6">
<Form {...form}>
<div className="space-y-6">
<FormField
control={form.control}
name="email"
render={({ field }) => (
<FormItem className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<FormLabel htmlFor="notification-email">
{t("notification.email.title")}
</FormLabel>
<FormDescription className="hidden md:block">
{t("notification.email.desc")}
</FormDescription>
</div>
<div
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
>
<FormControl>
<Input
id="notification-email"
className="text-md w-full border border-input bg-background p-2 hover:bg-accent hover:text-accent-foreground dark:[color-scheme:dark] md:w-72"
placeholder={t(
"notification.email.placeholder",
)}
{...field}
/>
</FormControl>
<FormDescription className="md:hidden">
{t("notification.email.desc")}
</FormDescription>
<FormMessage />
</div>
</FormItem>
)}
/>
<FormField
control={form.control}
name="cameras"
render={({ field }) => (
<FormItem className={SPLIT_ROW_CLASS_NAME}>
<div className="space-y-1.5">
<FormLabel className="text-base">
{t("notification.cameras.title")}
</FormLabel>
<FormDescription className="hidden md:block">
{t("notification.cameras.desc")}
</FormDescription>
</div>
<div
className={`${CONTROL_COLUMN_CLASS_NAME} space-y-1.5`}
>
{allCameras.length > 0 ? (
<div className="w-full space-y-2 rounded-lg bg-secondary p-4">
<FormField
control={form.control}
name="allEnabled"
render={({ field: allEnabledField }) => (
<FilterSwitch
label={t("cameras.all.title", {
ns: "components/filter",
})}
isChecked={allEnabledField.value}
onCheckedChange={(checked) => {
setCameraSelectionTouched(true);
if (checked) {
form.setValue("cameras", []);
}
allEnabledField.onChange(checked);
}}
/>
)}
/>
{allCameras.map((camera) => {
const currentCameras = Array.isArray(
field.value,
)
? field.value
: [];
return (
<FilterSwitch
key={camera.name}
label={camera.name}
type="camera"
isChecked={currentCameras.includes(
camera.name,
)}
onCheckedChange={(checked) => {
setCameraSelectionTouched(true);
const newCameras = checked
? Array.from(
new Set([
...currentCameras,
camera.name,
]),
)
: currentCameras.filter(
(value) => value !== camera.name,
);
field.onChange(newCameras);
form.setValue("allEnabled", false);
}}
/>
);
})}
</div>
) : (
<div className="font-normal text-destructive">
{t("notification.cameras.noCameras")}
</div>
)}
<FormDescription className="md:hidden">
{t("notification.cameras.desc")}
</FormDescription>
<FormMessage />
</div>
</FormItem>
)}
/>
</div>
</Form>
</div>
</SettingsGroupCard>
)}
<div className="space-y-6">
<SettingsGroupCard title={t("notification.deviceSpecific")}>
<div className={cn("space-y-2", isAdmin && "md:max-w-[50%]")}>
<Button
aria-label={t("notification.registerDevice")}
className="w-full md:w-auto"
disabled={!shouldFetchPubKey || publicKey == undefined}
onClick={() => {
if (registration == null) {
Notification.requestPermission().then((permission) => {
if (permission === "granted") {
navigator.serviceWorker
.register(NOTIFICATION_SERVICE_WORKER)
.then((workerRegistration) => {
setRegistration(workerRegistration);
if (workerRegistration.active) {
subscribeToNotifications(workerRegistration);
} else {
setTimeout(
() =>
subscribeToNotifications(
workerRegistration,
),
1000,
);
}
});
}
});
} else {
registration.pushManager
.getSubscription()
.then((pushSubscription) => {
pushSubscription?.unsubscribe();
registration.unregister();
setRegistration(null);
removeMessage(
"notification_settings",
"registration",
);
});
}
}}
>
{registration != null
? t("notification.unregisterDevice")
: t("notification.registerDevice")}
</Button>
{isAdmin && registration != null && registration.active && (
<Button
className="w-full md:w-auto"
aria-label={t("notification.sendTestNotification")}
onClick={() => sendTestNotification("notification_test")}
>
{t("notification.sendTestNotification")}
</Button>
)}
</div>
</SettingsGroupCard>
{isAdmin && notificationCameras.length > 0 && (
<SettingsGroupCard title={t("notification.globalSettings.title")}>
<div className="space-y-4">
<div className="flex max-w-xl flex-col gap-2 text-sm text-primary-variant">
<p>{t("notification.globalSettings.desc")}</p>
</div>
<div className="w-full rounded-lg bg-secondary p-5 md:max-w-2xl">
<div className="grid gap-6">
{notificationCameras.map((item) => (
<CameraNotificationSwitch
key={item.name}
config={config}
camera={item.name}
/>
))}
</div>
</div>
</div>
</SettingsGroupCard>
)}
</div>
</div>
</div>
</div>
);
}
type CameraNotificationSwitchProps = {
config?: FrigateConfig;
camera: string;
};
export function CameraNotificationSwitch({
config,
camera,
}: CameraNotificationSwitchProps) {
const { t } = useTranslation(["views/settings"]);
const { payload: notificationState, send: sendNotification } =
useNotifications(camera);
const { payload: notificationSuspendUntil, send: sendNotificationSuspend } =
useNotificationSuspend(camera);
const [isSuspended, setIsSuspended] = useState<boolean>(false);
useEffect(() => {
if (notificationSuspendUntil) {
setIsSuspended(
notificationSuspendUntil !== "0" || notificationState === "OFF",
);
}
}, [notificationSuspendUntil, notificationState]);
const handleSuspend = (duration: string) => {
setIsSuspended(true);
if (duration == "off") {
sendNotification("OFF");
} else {
sendNotificationSuspend(parseInt(duration));
}
};
const handleCancelSuspension = () => {
sendNotification("ON");
sendNotificationSuspend(0);
};
const locale = useDateLocale();
const formatSuspendedUntil = (timestamp: string) => {
if (timestamp === "0") return t("time.untilForRestart", { ns: "common" });
const time = formatUnixTimestampToDateTime(parseInt(timestamp), {
time_style: "medium",
date_style: "medium",
timezone: config?.ui.timezone,
date_format:
config?.ui.time_format == "24hour"
? t("time.formattedTimestampMonthDayHourMinute.24hour", {
ns: "common",
})
: t("time.formattedTimestampMonthDayHourMinute.12hour", {
ns: "common",
}),
locale: locale,
});
return t("time.untilForTime", { ns: "common", time });
};
return (
<div className="flex items-center justify-between gap-2">
<div className="flex flex-col items-start justify-start">
<div className="flex flex-row items-center justify-start gap-3">
{!isSuspended ? (
<LuCheck className="size-6 text-success" />
) : (
<LuX className="size-6 text-danger" />
)}
<div className="flex flex-col">
<CameraNameLabel
className="text-md cursor-pointer text-primary smart-capitalize"
htmlFor="camera"
camera={camera}
/>
{!isSuspended ? (
<div className="flex flex-row items-center gap-2 text-sm text-success">
{t("notification.active")}
</div>
) : (
<div className="flex flex-row items-center gap-2 text-sm text-danger">
{t("notification.suspended", {
time: formatSuspendedUntil(notificationSuspendUntil),
})}
</div>
)}
</div>
</div>
</div>
{!isSuspended ? (
<Select onValueChange={handleSuspend}>
<SelectTrigger className="w-auto">
<SelectValue placeholder={t("notification.suspendTime.suspend")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="5">
{t("notification.suspendTime.5minutes")}
</SelectItem>
<SelectItem value="10">
{t("notification.suspendTime.10minutes")}
</SelectItem>
<SelectItem value="30">
{t("notification.suspendTime.30minutes")}
</SelectItem>
<SelectItem value="60">
{t("notification.suspendTime.1hour")}
</SelectItem>
<SelectItem value="840">
{t("notification.suspendTime.12hours")}
</SelectItem>
<SelectItem value="1440">
{t("notification.suspendTime.24hours")}
</SelectItem>
<SelectItem value="off">
{t("notification.suspendTime.untilRestart")}
</SelectItem>
</SelectContent>
</Select>
) : (
<Button
variant="destructive"
size="sm"
onClick={handleCancelSuspension}
>
{t("notification.cancelSuspension")}
</Button>
)}
</div>
);
}
@@ -0,0 +1,201 @@
import { useMemo } from "react";
import type { ComponentType } from "react";
import { useTranslation } from "react-i18next";
import cloneDeep from "lodash/cloneDeep";
import get from "lodash/get";
import set from "lodash/set";
import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import { TagsWidget } from "@/components/config-form/theme/widgets/TagsWidget";
import { isJsonObject } from "@/lib/utils";
import type { ConfigSectionData, JsonObject } from "@/types/configForm";
import type { SectionRendererProps } from "./registry";
const EMPTY_FORM_DATA: JsonObject = {};
const RoleMapTags = TagsWidget as unknown as ComponentType<{
id: string;
value: string[];
onChange: (value: unknown) => void;
schema: { title: string };
}>;
export default function ProxyRoleMap({ formContext }: SectionRendererProps) {
const { t } = useTranslation(["views/settings", "config/global"]);
const fullFormData =
(formContext?.formData as JsonObject | undefined) ?? EMPTY_FORM_DATA;
const onFormDataChange = formContext?.onFormDataChange;
const roleHeader = get(fullFormData, "header_map.role");
const hasRoleHeader =
typeof roleHeader === "string" && roleHeader.trim().length > 0;
const roleMap = useMemo(() => {
const roleMapValue = get(fullFormData, "header_map.role_map");
return isJsonObject(roleMapValue)
? (roleMapValue as Record<string, string[]>)
: {};
}, [fullFormData]);
const roleOptions = useMemo(() => {
const rolesFromConfig = formContext?.fullConfig?.auth?.roles
? Object.keys(formContext.fullConfig.auth.roles)
: [];
const roles =
rolesFromConfig.length > 0 ? rolesFromConfig : ["admin", "viewer"];
return Array.from(new Set([...roles, ...Object.keys(roleMap)])).sort();
}, [formContext?.fullConfig, roleMap]);
if (!onFormDataChange || !formContext?.formData) {
return null;
}
if (!hasRoleHeader) {
return null;
}
const usedRoles = new Set(Object.keys(roleMap));
const nextRole = roleOptions.find((role) => !usedRoles.has(role));
const updateRoleMap = (nextRoleMap: Record<string, string[]>) => {
const nextFormData = cloneDeep(fullFormData) as JsonObject;
set(nextFormData, "header_map.role_map", nextRoleMap);
onFormDataChange(nextFormData as ConfigSectionData);
};
const handleAdd = () => {
if (!nextRole) return;
updateRoleMap({
...roleMap,
[nextRole]: [],
});
};
const handleRemove = (role: string) => {
const next = { ...roleMap };
delete next[role];
updateRoleMap(next);
};
const handleRoleChange = (currentRole: string, newRole: string) => {
if (currentRole === newRole) return;
const next = { ...roleMap } as Record<string, string[]>;
const groups = next[currentRole] ?? [];
delete next[currentRole];
next[newRole] = groups;
updateRoleMap(next);
};
const handleGroupsChange = (role: string, groups: unknown) => {
updateRoleMap({
...roleMap,
[role]: Array.isArray(groups) ? groups : [],
});
};
const roleMapLabel = t("proxy.header_map.role_map.label", {
ns: "config/global",
});
const roleMapDescription = t("proxy.header_map.role_map.description", {
ns: "config/global",
});
return (
<div className="space-y-4">
<div className="space-y-1">
<Label className="text-sm font-medium">{roleMapLabel}</Label>
<p className="text-xs text-muted-foreground">{roleMapDescription}</p>
</div>
{Object.keys(roleMap).length === 0 && (
<p className="text-sm italic text-muted-foreground">
{t("configForm.roleMap.empty", { ns: "views/settings" })}
</p>
)}
{Object.entries(roleMap).map(([role, groups], index) => {
const rowId = `role-map-${role}-${index}`;
const roleLabel = t("configForm.roleMap.roleLabel", {
ns: "views/settings",
});
const groupsLabel = t("configForm.roleMap.groupsLabel", {
ns: "views/settings",
});
const normalizedGroups = Array.isArray(groups) ? groups : [];
return (
<div key={rowId} className="grid grid-cols-12 items-start gap-2">
<div className="col-span-12 space-y-2 md:col-span-4">
<Label htmlFor={`${rowId}-role`}>{roleLabel}</Label>
<Select
value={role}
onValueChange={(next) => handleRoleChange(role, next)}
>
<SelectTrigger id={`${rowId}-role`} className="w-full">
<SelectValue placeholder={roleLabel} />
</SelectTrigger>
<SelectContent>
{roleOptions.map((option) => (
<SelectItem key={option} value={option}>
{option}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="col-span-12 space-y-2 md:col-span-7">
<Label htmlFor={`${rowId}-groups`}>{groupsLabel}</Label>
<RoleMapTags
id={`${rowId}-groups`}
value={normalizedGroups}
onChange={(next) => handleGroupsChange(role, next)}
schema={{ title: groupsLabel }}
/>
</div>
<div className="col-span-12 flex items-center md:col-span-1 md:justify-center md:pt-7">
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => handleRemove(role)}
aria-label={t("configForm.roleMap.remove", {
ns: "views/settings",
})}
title={t("configForm.roleMap.remove", {
ns: "views/settings",
})}
>
<LuTrash2 className="h-4 w-4" />
</Button>
</div>
</div>
);
})}
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAdd}
disabled={!nextRole}
className="gap-2"
>
<LuPlus className="h-4 w-4" />
{t("configForm.roleMap.addMapping", {
ns: "views/settings",
})}
</Button>
</div>
);
}
@@ -0,0 +1,106 @@
import { useState } from "react";
import axios from "axios";
import { Button, buttonVariants } from "@/components/ui/button";
import { Trans, useTranslation } from "react-i18next";
import { toast } from "sonner";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
export default function SemanticSearchReindex() {
const { t } = useTranslation("views/settings");
const [isLoading, setIsLoading] = useState(false);
const [isDialogOpen, setIsDialogOpen] = useState(false);
const onReindex = async () => {
setIsLoading(true);
try {
const res = await axios.put("/reindex");
if (res.status === 202) {
toast.success(t("enrichments.semanticSearch.reindexNow.success"), {
position: "top-center",
});
} else {
toast.error(
t("enrichments.semanticSearch.reindexNow.error", {
errorMessage: res.statusText,
}),
{ position: "top-center" },
);
}
} catch (caught) {
const error = caught as {
response?: { data?: { message?: string; detail?: string } };
};
const errorMessage =
error.response?.data?.message || error.response?.data?.detail || "";
toast.error(
t("enrichments.semanticSearch.reindexNow.error", {
errorMessage: errorMessage || undefined,
}),
{ position: "top-center" },
);
} finally {
setIsLoading(false);
}
};
return (
<>
<div className="flex flex-col space-y-1">
<div className="flex">
<Button
variant="default"
size="sm"
onClick={() => setIsDialogOpen(true)}
disabled={isLoading}
aria-label={t("enrichments.semanticSearch.reindexNow.label")}
>
{t("enrichments.semanticSearch.reindexNow.label")}
</Button>
</div>
<div className="mt-2 text-xs text-muted-foreground">
<Trans ns="views/settings">
enrichments.semanticSearch.reindexNow.desc
</Trans>
</div>
<AlertDialog open={isDialogOpen} onOpenChange={setIsDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{t("enrichments.semanticSearch.reindexNow.confirmTitle")}
</AlertDialogTitle>
<AlertDialogDescription>
<Trans ns="views/settings">
enrichments.semanticSearch.reindexNow.confirmDesc
</Trans>
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel onClick={() => setIsDialogOpen(false)}>
{t("button.cancel", { ns: "common" })}
</AlertDialogCancel>
<AlertDialogAction
className={buttonVariants({ variant: "select" })}
onClick={async () => {
await onReindex();
setIsDialogOpen(false);
}}
>
{t("enrichments.semanticSearch.reindexNow.confirmButton")}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
</>
);
}
@@ -0,0 +1,57 @@
import type { ComponentType } from "react";
import SemanticSearchReindex from "./SemanticSearchReindex.tsx";
import CameraReviewStatusToggles from "./CameraReviewStatusToggles";
import ProxyRoleMap from "./ProxyRoleMap";
import NotificationsSettingsExtras from "./NotificationsSettingsExtras";
import type { ConfigFormContext } from "@/types/configForm";
// Props that will be injected into all section renderers
export type SectionRendererProps = {
selectedCamera?: string;
setUnsavedChanges?: (hasChanges: boolean) => void;
formContext?: ConfigFormContext;
[key: string]: unknown; // Allow additional props from uiSchema
};
export type RendererComponent = ComponentType<SectionRendererProps>;
export type SectionRenderers = Record<
string,
Record<string, RendererComponent>
>;
// Section renderers registry
// Used to register custom renderer components for specific config sections.
// Maps a section key (e.g., `semantic_search`) to a mapping of renderer
// names to React components. These names are referenced from `uiSchema`
// descriptors (e.g., `{ "ui:after": { render: "SemanticSearchReindex" } }`) and
// are resolved by `FieldTemplate` through `formContext.renderers`.
//
// RUNTIME PROPS INJECTION:
// All renderers automatically receive the following props from BaseSection:
// - selectedCamera?: string - The current camera name (camera-level only)
// - setUnsavedChanges?: (hasChanges: boolean) => void - Callback to signal unsaved state
//
// Additional static props can be passed via uiSchema:
// { "ui:after": { render: "MyRenderer", props: { customProp: "value" } } }
//
// ADDING NEW RENDERERS:
// 1. Create your component accepting SectionRendererProps
// 2. Import and add it to the appropriate section in this registry
// 3. Reference it in your section's uiSchema using the { render: "ComponentName" } syntax
export const sectionRenderers: SectionRenderers = {
semantic_search: {
SemanticSearchReindex,
},
review: {
CameraReviewStatusToggles,
},
proxy: {
ProxyRoleMap,
},
notifications: {
NotificationsSettingsExtras,
},
};
export default sectionRenderers;