mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-07-26 05:39:04 +03:00
use react-jsonschema-form for UI config
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user