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