use react-jsonschema-form for UI config

This commit is contained in:
Josh Hawkins
2026-02-27 09:37:56 -06:00
parent eeefbf2bb5
commit 68c74fef05
48 changed files with 4059 additions and 11 deletions
+265
View File
@@ -0,0 +1,265 @@
// Camera Configuration View
// Per-camera configuration with tab navigation and override indicators
import { useMemo, useCallback, useState } from "react";
import useSWR from "swr";
import { useTranslation } from "react-i18next";
import {
DetectSection,
RecordSection,
SnapshotsSection,
MotionSection,
ObjectsSection,
ReviewSection,
AudioSection,
NotificationsSection,
LiveSection,
TimestampSection,
} from "@/components/config-form/sections";
import { useAllCameraOverrides } from "@/hooks/use-config-override";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Badge } from "@/components/ui/badge";
import ActivityIndicator from "@/components/indicators/activity-indicator";
import { cn } from "@/lib/utils";
interface CameraConfigViewProps {
/** Currently selected camera (from parent) */
selectedCamera?: string;
/** Callback when unsaved changes state changes */
setUnsavedChanges?: React.Dispatch<React.SetStateAction<boolean>>;
}
export default function CameraConfigView({
selectedCamera: externalSelectedCamera,
setUnsavedChanges,
}: CameraConfigViewProps) {
const { t } = useTranslation(["views/settings"]);
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
// Get list of cameras
const cameras = useMemo(() => {
if (!config?.cameras) return [];
return Object.keys(config.cameras).sort();
}, [config]);
// Selected camera state (use external if provided, else internal)
const [internalSelectedCamera, setInternalSelectedCamera] = useState<string>(
cameras[0] || "",
);
const selectedCamera = externalSelectedCamera || internalSelectedCamera;
// Get overridden sections for current camera
const overriddenSections = useAllCameraOverrides(config, selectedCamera);
const handleSave = useCallback(() => {
refreshConfig();
setUnsavedChanges?.(false);
}, [refreshConfig, setUnsavedChanges]);
const handleCameraChange = useCallback((camera: string) => {
setInternalSelectedCamera(camera);
}, []);
if (!config) {
return (
<div className="flex h-full items-center justify-center">
<ActivityIndicator />
</div>
);
}
if (cameras.length === 0) {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
{t("configForm.camera.noCameras", {
defaultValue: "No cameras configured",
})}
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">
{t("configForm.camera.title", {
defaultValue: "Camera Configuration",
})}
</h2>
<p className="text-muted-foreground">
{t("configForm.camera.description", {
defaultValue:
"Configure settings for individual cameras. Overridden settings are highlighted.",
})}
</p>
</div>
{/* Camera Tabs - Only show if not externally controlled */}
{!externalSelectedCamera && (
<Tabs
value={selectedCamera}
onValueChange={handleCameraChange}
className="w-full"
>
<ScrollArea className="w-full">
<TabsList className="inline-flex w-max">
{cameras.map((camera) => {
const cameraOverrides = overriddenSections.filter((s) =>
s.startsWith(camera),
);
const hasOverrides = cameraOverrides.length > 0;
const cameraConfig = config.cameras[camera];
const displayName = cameraConfig?.name || camera;
return (
<TabsTrigger
key={camera}
value={camera}
className="relative gap-2"
>
{displayName}
{hasOverrides && (
<Badge variant="secondary" className="ml-1 h-5 px-1.5">
{cameraOverrides.length}
</Badge>
)}
</TabsTrigger>
);
})}
</TabsList>
</ScrollArea>
{cameras.map((camera) => (
<TabsContent key={camera} value={camera} className="mt-4">
<CameraConfigContent
cameraName={camera}
config={config}
overriddenSections={overriddenSections}
onSave={handleSave}
/>
</TabsContent>
))}
</Tabs>
)}
{/* Direct content when externally controlled */}
{externalSelectedCamera && (
<CameraConfigContent
cameraName={externalSelectedCamera}
config={config}
overriddenSections={overriddenSections}
onSave={handleSave}
/>
)}
</div>
);
}
interface CameraConfigContentProps {
cameraName: string;
config: FrigateConfig;
overriddenSections: string[];
onSave: () => void;
}
function CameraConfigContent({
cameraName,
config,
overriddenSections,
onSave,
}: CameraConfigContentProps) {
const { t } = useTranslation(["views/settings"]);
const [activeSection, setActiveSection] = useState("detect");
const cameraConfig = config.cameras?.[cameraName];
if (!cameraConfig) {
return (
<div className="text-muted-foreground">
{t("configForm.camera.notFound", { defaultValue: "Camera not found" })}
</div>
);
}
const sections = [
{ key: "detect", label: "Detect", component: DetectSection },
{ key: "record", label: "Record", component: RecordSection },
{ key: "snapshots", label: "Snapshots", component: SnapshotsSection },
{ key: "motion", label: "Motion", component: MotionSection },
{ key: "objects", label: "Objects", component: ObjectsSection },
{ key: "review", label: "Review", component: ReviewSection },
{ key: "audio", label: "Audio", component: AudioSection },
{
key: "notifications",
label: "Notifications",
component: NotificationsSection,
},
{ key: "live", label: "Live", component: LiveSection },
{ key: "timestamp_style", label: "Timestamp", component: TimestampSection },
];
return (
<div className="flex gap-6">
{/* Section Navigation */}
<nav className="w-48 shrink-0">
<ul className="space-y-1">
{sections.map((section) => {
const isOverridden = overriddenSections.includes(section.key);
return (
<li key={section.key}>
<button
onClick={() => setActiveSection(section.key)}
className={cn(
"flex w-full items-center justify-between rounded-md px-3 py-2 text-sm transition-colors",
activeSection === section.key
? "bg-accent text-accent-foreground"
: "hover:bg-muted",
)}
>
<span>
{t(`configForm.${section.key}.title`, {
defaultValue: section.label,
})}
</span>
{isOverridden && (
<Badge variant="secondary" className="h-5 px-1.5 text-xs">
{t("common.modified", { defaultValue: "Modified" })}
</Badge>
)}
</button>
</li>
);
})}
</ul>
</nav>
{/* Section Content */}
<ScrollArea className="h-[calc(100vh-300px)] flex-1">
<div className="pr-4">
{sections.map((section) => {
const SectionComponent = section.component;
return (
<div
key={section.key}
className={cn(
activeSection === section.key ? "block" : "hidden",
)}
>
<SectionComponent
level="camera"
cameraName={cameraName}
showOverrideIndicator
onSave={onSave}
/>
</div>
);
})}
</div>
</ScrollArea>
</div>
);
}
+328
View File
@@ -0,0 +1,328 @@
// Global Configuration View
// Main view for configuring global Frigate settings
import { useMemo, useCallback, useState } from "react";
import useSWR from "swr";
import axios from "axios";
import { toast } from "sonner";
import { useTranslation } from "react-i18next";
import { ConfigForm } from "@/components/config-form/ConfigForm";
import {
DetectSection,
RecordSection,
SnapshotsSection,
MotionSection,
ObjectsSection,
ReviewSection,
AudioSection,
NotificationsSection,
LiveSection,
TimestampSection,
} from "@/components/config-form/sections";
import type { RJSFSchema } from "@rjsf/utils";
import type { FrigateConfig } from "@/types/frigateConfig";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { ScrollArea } from "@/components/ui/scroll-area";
import { extractSchemaSection } from "@/lib/config-schema";
import ActivityIndicator from "@/components/indicators/activity-indicator";
// Section configurations for global-only settings
const globalSectionConfigs: Record<
string,
{ fieldOrder?: string[]; hiddenFields?: string[]; advancedFields?: string[] }
> = {
mqtt: {
fieldOrder: [
"enabled",
"host",
"port",
"user",
"password",
"topic_prefix",
"client_id",
"stats_interval",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
advancedFields: [
"stats_interval",
"tls_ca_certs",
"tls_client_cert",
"tls_client_key",
"tls_insecure",
],
},
database: {
fieldOrder: ["path"],
advancedFields: [],
},
auth: {
fieldOrder: [
"enabled",
"reset_admin_password",
"native_oauth_url",
"failed_login_rate_limit",
"trusted_proxies",
],
advancedFields: ["failed_login_rate_limit", "trusted_proxies"],
},
tls: {
fieldOrder: ["enabled", "cert", "key"],
advancedFields: [],
},
telemetry: {
fieldOrder: ["network_interfaces", "stats", "version_check"],
advancedFields: ["stats"],
},
birdseye: {
fieldOrder: [
"enabled",
"restream",
"width",
"height",
"quality",
"mode",
"layout",
"inactivity_threshold",
],
advancedFields: ["width", "height", "quality", "inactivity_threshold"],
},
semantic_search: {
fieldOrder: ["enabled", "reindex", "model_size"],
advancedFields: ["reindex"],
},
face_recognition: {
fieldOrder: ["enabled", "threshold", "min_area", "model_size"],
advancedFields: ["threshold", "min_area"],
},
lpr: {
fieldOrder: [
"enabled",
"threshold",
"min_area",
"min_ratio",
"max_ratio",
"model_size",
],
advancedFields: ["threshold", "min_area", "min_ratio", "max_ratio"],
},
};
interface GlobalConfigSectionProps {
sectionKey: string;
schema: RJSFSchema | null;
config: FrigateConfig | undefined;
onSave: () => void;
}
function GlobalConfigSection({
sectionKey,
schema,
config,
onSave,
}: GlobalConfigSectionProps) {
const { t } = useTranslation(["views/settings"]);
const formData = useMemo((): Record<string, unknown> => {
if (!config) return {} as Record<string, unknown>;
const value = (config as unknown as Record<string, unknown>)[sectionKey];
return (
(value as Record<string, unknown>) || ({} as Record<string, unknown>)
);
}, [config, sectionKey]);
const handleSubmit = useCallback(
async (data: Record<string, unknown>) => {
try {
await axios.put("config/set", {
requires_restart: 1,
config_data: {
[sectionKey]: data,
},
});
toast.success(
t(`configForm.${sectionKey}.toast.success`, {
defaultValue: "Settings saved successfully",
}),
);
onSave();
} catch (error) {
toast.error(
t(`configForm.${sectionKey}.toast.error`, {
defaultValue: "Failed to save settings",
}),
);
}
},
[sectionKey, t, onSave],
);
if (!schema) {
return null;
}
const sectionConfig = globalSectionConfigs[sectionKey] || {};
return (
<Card>
<CardHeader>
<CardTitle className="text-lg">
{t(`configForm.${sectionKey}.title`, {
defaultValue:
sectionKey.charAt(0).toUpperCase() + sectionKey.slice(1),
})}
</CardTitle>
</CardHeader>
<CardContent>
<ConfigForm
schema={schema}
formData={formData}
onSubmit={handleSubmit}
fieldOrder={sectionConfig.fieldOrder}
hiddenFields={sectionConfig.hiddenFields}
advancedFields={sectionConfig.advancedFields}
/>
</CardContent>
</Card>
);
}
export default function GlobalConfigView() {
const { t } = useTranslation(["views/settings"]);
const [activeTab, setActiveTab] = useState("shared");
const { data: config, mutate: refreshConfig } =
useSWR<FrigateConfig>("config");
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
const handleSave = useCallback(() => {
refreshConfig();
}, [refreshConfig]);
if (!config || !schema) {
return (
<div className="flex h-full items-center justify-center">
<ActivityIndicator />
</div>
);
}
return (
<div className="space-y-6">
<div>
<h2 className="text-2xl font-bold tracking-tight">
{t("configForm.global.title", {
defaultValue: "Global Configuration",
})}
</h2>
<p className="text-muted-foreground">
{t("configForm.global.description", {
defaultValue:
"Configure global settings that apply to all cameras by default.",
})}
</p>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
<TabsList className="grid w-full grid-cols-3">
<TabsTrigger value="shared">
{t("configForm.global.tabs.shared", {
defaultValue: "Shared Defaults",
})}
</TabsTrigger>
<TabsTrigger value="system">
{t("configForm.global.tabs.system", { defaultValue: "System" })}
</TabsTrigger>
<TabsTrigger value="integrations">
{t("configForm.global.tabs.integrations", {
defaultValue: "Integrations",
})}
</TabsTrigger>
</TabsList>
<ScrollArea className="h-[calc(100vh-300px)]">
<TabsContent value="shared" className="space-y-6 p-1">
{/* Shared config sections - these can be overridden per camera */}
<DetectSection level="global" onSave={handleSave} />
<RecordSection level="global" onSave={handleSave} />
<SnapshotsSection level="global" onSave={handleSave} />
<MotionSection level="global" onSave={handleSave} />
<ObjectsSection level="global" onSave={handleSave} />
<ReviewSection level="global" onSave={handleSave} />
<AudioSection level="global" onSave={handleSave} />
<NotificationsSection level="global" onSave={handleSave} />
<LiveSection level="global" onSave={handleSave} />
<TimestampSection level="global" onSave={handleSave} />
</TabsContent>
<TabsContent value="system" className="space-y-6 p-1">
{/* System configuration sections */}
<GlobalConfigSection
sectionKey="database"
schema={extractSchemaSection(schema, "database")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="tls"
schema={extractSchemaSection(schema, "tls")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="auth"
schema={extractSchemaSection(schema, "auth")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="telemetry"
schema={extractSchemaSection(schema, "telemetry")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="birdseye"
schema={extractSchemaSection(schema, "birdseye")}
config={config}
onSave={handleSave}
/>
</TabsContent>
<TabsContent value="integrations" className="space-y-6 p-1">
{/* Integration configuration sections */}
<GlobalConfigSection
sectionKey="mqtt"
schema={extractSchemaSection(schema, "mqtt")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="semantic_search"
schema={extractSchemaSection(schema, "semantic_search")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="face_recognition"
schema={extractSchemaSection(schema, "face_recognition")}
config={config}
onSave={handleSave}
/>
<GlobalConfigSection
sectionKey="lpr"
schema={extractSchemaSection(schema, "lpr")}
config={config}
onSave={handleSave}
/>
</TabsContent>
</ScrollArea>
</Tabs>
</div>
);
}