mirror of
https://github.com/blakeblackshear/frigate.git
synced 2026-03-10 10:33:11 +03:00
805 lines
22 KiB
TypeScript
805 lines
22 KiB
TypeScript
// Global Configuration View
|
|
// Main view for configuring global Frigate settings
|
|
|
|
import { useMemo, useCallback, useState, useEffect, useRef } 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 } from "@/components/config-form/sections/DetectSection";
|
|
import { RecordSection } from "@/components/config-form/sections/RecordSection";
|
|
import { SnapshotsSection } from "@/components/config-form/sections/SnapshotsSection";
|
|
import { MotionSection } from "@/components/config-form/sections/MotionSection";
|
|
import { ObjectsSection } from "@/components/config-form/sections/ObjectsSection";
|
|
import { ReviewSection } from "@/components/config-form/sections/ReviewSection";
|
|
import { AudioSection } from "@/components/config-form/sections/AudioSection";
|
|
import { NotificationsSection } from "@/components/config-form/sections/NotificationsSection";
|
|
import { LiveSection } from "@/components/config-form/sections/LiveSection";
|
|
import { TimestampSection } from "@/components/config-form/sections/TimestampSection";
|
|
import type { RJSFSchema } from "@rjsf/utils";
|
|
import type { FrigateConfig } from "@/types/frigateConfig";
|
|
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { extractSchemaSection } from "@/lib/config-schema";
|
|
import ActivityIndicator from "@/components/indicators/activity-indicator";
|
|
import Heading from "@/components/ui/heading";
|
|
import { LuSave } from "react-icons/lu";
|
|
import isEqual from "lodash/isEqual";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
// Shared sections that can be overridden at camera level
|
|
const sharedSections = [
|
|
{ key: "detect", i18nNamespace: "config/detect", component: DetectSection },
|
|
{ key: "record", i18nNamespace: "config/record", component: RecordSection },
|
|
{
|
|
key: "snapshots",
|
|
i18nNamespace: "config/snapshots",
|
|
component: SnapshotsSection,
|
|
},
|
|
{ key: "motion", i18nNamespace: "config/motion", component: MotionSection },
|
|
{
|
|
key: "objects",
|
|
i18nNamespace: "config/objects",
|
|
component: ObjectsSection,
|
|
},
|
|
{ key: "review", i18nNamespace: "config/review", component: ReviewSection },
|
|
{ key: "audio", i18nNamespace: "config/audio", component: AudioSection },
|
|
{
|
|
key: "notifications",
|
|
i18nNamespace: "config/notifications",
|
|
component: NotificationsSection,
|
|
},
|
|
{ key: "live", i18nNamespace: "config/live", component: LiveSection },
|
|
{
|
|
key: "timestamp_style",
|
|
i18nNamespace: "config/timestamp_style",
|
|
component: TimestampSection,
|
|
},
|
|
];
|
|
|
|
// Section configurations for global-only settings (system and integrations)
|
|
const globalSectionConfigs: Record<
|
|
string,
|
|
{
|
|
fieldOrder?: string[];
|
|
hiddenFields?: string[];
|
|
advancedFields?: string[];
|
|
liveValidate?: boolean;
|
|
i18nNamespace: string;
|
|
}
|
|
> = {
|
|
mqtt: {
|
|
i18nNamespace: "config/mqtt",
|
|
fieldOrder: [
|
|
"enabled",
|
|
"host",
|
|
"port",
|
|
"user",
|
|
"password",
|
|
"topic_prefix",
|
|
"client_id",
|
|
"stats_interval",
|
|
"qos",
|
|
"tls_ca_certs",
|
|
"tls_client_cert",
|
|
"tls_client_key",
|
|
"tls_insecure",
|
|
],
|
|
advancedFields: [
|
|
"stats_interval",
|
|
"qos",
|
|
"tls_ca_certs",
|
|
"tls_client_cert",
|
|
"tls_client_key",
|
|
"tls_insecure",
|
|
],
|
|
},
|
|
database: {
|
|
i18nNamespace: "config/database",
|
|
fieldOrder: ["path"],
|
|
advancedFields: [],
|
|
},
|
|
auth: {
|
|
i18nNamespace: "config/auth",
|
|
fieldOrder: [
|
|
"enabled",
|
|
"reset_admin_password",
|
|
"cookie_name",
|
|
"cookie_secure",
|
|
"session_length",
|
|
"refresh_time",
|
|
"native_oauth_url",
|
|
"failed_login_rate_limit",
|
|
"trusted_proxies",
|
|
"hash_iterations",
|
|
"roles",
|
|
],
|
|
hiddenFields: ["admin_first_time_login"],
|
|
advancedFields: [
|
|
"cookie_name",
|
|
"cookie_secure",
|
|
"session_length",
|
|
"refresh_time",
|
|
"failed_login_rate_limit",
|
|
"trusted_proxies",
|
|
"hash_iterations",
|
|
"roles",
|
|
"admin_first_time_login",
|
|
],
|
|
},
|
|
tls: {
|
|
i18nNamespace: "config/tls",
|
|
fieldOrder: ["enabled", "cert", "key"],
|
|
advancedFields: [],
|
|
},
|
|
networking: {
|
|
i18nNamespace: "config/networking",
|
|
fieldOrder: ["ipv6"],
|
|
advancedFields: [],
|
|
},
|
|
proxy: {
|
|
i18nNamespace: "config/proxy",
|
|
fieldOrder: [
|
|
"header_map",
|
|
"logout_url",
|
|
"auth_secret",
|
|
"default_role",
|
|
"separator",
|
|
],
|
|
advancedFields: ["header_map", "auth_secret", "separator"],
|
|
liveValidate: true,
|
|
},
|
|
ui: {
|
|
i18nNamespace: "config/ui",
|
|
fieldOrder: [
|
|
"timezone",
|
|
"time_format",
|
|
"date_style",
|
|
"time_style",
|
|
"unit_system",
|
|
],
|
|
advancedFields: [],
|
|
},
|
|
logger: {
|
|
i18nNamespace: "config/logger",
|
|
fieldOrder: ["default", "logs"],
|
|
advancedFields: ["logs"],
|
|
},
|
|
environment_vars: {
|
|
i18nNamespace: "config/environment_vars",
|
|
fieldOrder: [],
|
|
advancedFields: [],
|
|
},
|
|
telemetry: {
|
|
i18nNamespace: "config/telemetry",
|
|
fieldOrder: ["network_interfaces", "stats", "version_check"],
|
|
advancedFields: ["stats"],
|
|
},
|
|
birdseye: {
|
|
i18nNamespace: "config/birdseye",
|
|
fieldOrder: [
|
|
"enabled",
|
|
"restream",
|
|
"width",
|
|
"height",
|
|
"quality",
|
|
"mode",
|
|
"layout",
|
|
"inactivity_threshold",
|
|
"idle_heartbeat_fps",
|
|
],
|
|
advancedFields: ["width", "height", "quality", "inactivity_threshold"],
|
|
},
|
|
ffmpeg: {
|
|
i18nNamespace: "config/ffmpeg",
|
|
fieldOrder: [
|
|
"path",
|
|
"global_args",
|
|
"hwaccel_args",
|
|
"input_args",
|
|
"output_args",
|
|
"retry_interval",
|
|
"apple_compatibility",
|
|
"gpu",
|
|
],
|
|
advancedFields: [
|
|
"global_args",
|
|
"hwaccel_args",
|
|
"input_args",
|
|
"output_args",
|
|
"retry_interval",
|
|
"apple_compatibility",
|
|
"gpu",
|
|
],
|
|
},
|
|
detectors: {
|
|
i18nNamespace: "config/detectors",
|
|
fieldOrder: [],
|
|
advancedFields: [],
|
|
},
|
|
model: {
|
|
i18nNamespace: "config/model",
|
|
fieldOrder: [
|
|
"path",
|
|
"labelmap_path",
|
|
"width",
|
|
"height",
|
|
"input_pixel_format",
|
|
"input_tensor",
|
|
"input_dtype",
|
|
"model_type",
|
|
"labelmap",
|
|
"attributes_map",
|
|
],
|
|
advancedFields: [
|
|
"labelmap",
|
|
"attributes_map",
|
|
"input_pixel_format",
|
|
"input_tensor",
|
|
"input_dtype",
|
|
"model_type",
|
|
],
|
|
},
|
|
genai: {
|
|
i18nNamespace: "config/genai",
|
|
fieldOrder: [
|
|
"provider",
|
|
"api_key",
|
|
"base_url",
|
|
"model",
|
|
"provider_options",
|
|
"runtime_options",
|
|
],
|
|
advancedFields: ["base_url", "provider_options", "runtime_options"],
|
|
},
|
|
classification: {
|
|
i18nNamespace: "config/classification",
|
|
fieldOrder: ["bird", "custom"],
|
|
advancedFields: [],
|
|
},
|
|
semantic_search: {
|
|
i18nNamespace: "config/semantic_search",
|
|
fieldOrder: ["enabled", "reindex", "model", "model_size", "device"],
|
|
advancedFields: ["reindex", "device"],
|
|
},
|
|
audio_transcription: {
|
|
i18nNamespace: "config/audio_transcription",
|
|
fieldOrder: ["enabled", "language", "device", "model_size", "live_enabled"],
|
|
advancedFields: ["language", "device", "model_size"],
|
|
},
|
|
face_recognition: {
|
|
i18nNamespace: "config/face_recognition",
|
|
fieldOrder: [
|
|
"enabled",
|
|
"model_size",
|
|
"unknown_score",
|
|
"detection_threshold",
|
|
"recognition_threshold",
|
|
"min_area",
|
|
"min_faces",
|
|
"save_attempts",
|
|
"blur_confidence_filter",
|
|
"device",
|
|
],
|
|
advancedFields: [
|
|
"unknown_score",
|
|
"detection_threshold",
|
|
"recognition_threshold",
|
|
"min_area",
|
|
"min_faces",
|
|
"save_attempts",
|
|
"blur_confidence_filter",
|
|
"device",
|
|
],
|
|
},
|
|
lpr: {
|
|
i18nNamespace: "config/lpr",
|
|
fieldOrder: [
|
|
"enabled",
|
|
"model_size",
|
|
"detection_threshold",
|
|
"min_area",
|
|
"recognition_threshold",
|
|
"min_plate_length",
|
|
"format",
|
|
"match_distance",
|
|
"known_plates",
|
|
"enhancement",
|
|
"debug_save_plates",
|
|
"device",
|
|
"replace_rules",
|
|
],
|
|
advancedFields: [
|
|
"detection_threshold",
|
|
"recognition_threshold",
|
|
"min_plate_length",
|
|
"format",
|
|
"match_distance",
|
|
"known_plates",
|
|
"enhancement",
|
|
"debug_save_plates",
|
|
"device",
|
|
"replace_rules",
|
|
],
|
|
},
|
|
go2rtc: {
|
|
i18nNamespace: "config/go2rtc",
|
|
fieldOrder: [],
|
|
advancedFields: [],
|
|
},
|
|
camera_groups: {
|
|
i18nNamespace: "config/camera_groups",
|
|
fieldOrder: ["cameras", "icon", "order"],
|
|
advancedFields: [],
|
|
},
|
|
safe_mode: {
|
|
i18nNamespace: "config/safe_mode",
|
|
fieldOrder: [],
|
|
advancedFields: [],
|
|
},
|
|
version: {
|
|
i18nNamespace: "config/version",
|
|
fieldOrder: [],
|
|
advancedFields: [],
|
|
},
|
|
};
|
|
|
|
// System sections (global only)
|
|
const systemSections = [
|
|
"database",
|
|
"tls",
|
|
"auth",
|
|
"networking",
|
|
"proxy",
|
|
"ui",
|
|
"logger",
|
|
"environment_vars",
|
|
"telemetry",
|
|
"birdseye",
|
|
"ffmpeg",
|
|
"detectors",
|
|
"model",
|
|
"classification",
|
|
"go2rtc",
|
|
"camera_groups",
|
|
"safe_mode",
|
|
"version",
|
|
];
|
|
|
|
// Integration sections (global only)
|
|
const integrationSections = [
|
|
"mqtt",
|
|
"audio_transcription",
|
|
"genai",
|
|
"semantic_search",
|
|
"face_recognition",
|
|
"lpr",
|
|
];
|
|
|
|
interface GlobalConfigSectionProps {
|
|
sectionKey: string;
|
|
schema: RJSFSchema | null;
|
|
config: FrigateConfig | undefined;
|
|
onSave: () => void;
|
|
title: string;
|
|
}
|
|
|
|
function GlobalConfigSection({
|
|
sectionKey,
|
|
schema,
|
|
config,
|
|
onSave,
|
|
title,
|
|
}: GlobalConfigSectionProps) {
|
|
const sectionConfig = globalSectionConfigs[sectionKey];
|
|
const { t } = useTranslation([
|
|
sectionConfig?.i18nNamespace || "common",
|
|
"views/settings",
|
|
"common",
|
|
]);
|
|
const [pendingData, setPendingData] = useState<unknown | null>(null);
|
|
const [isSaving, setIsSaving] = useState(false);
|
|
const [formKey, setFormKey] = useState(0);
|
|
const isResettingRef = useRef(false);
|
|
|
|
const formData = useMemo((): unknown => {
|
|
if (!config) return {};
|
|
return (config as unknown as Record<string, unknown>)[sectionKey];
|
|
}, [config, sectionKey]);
|
|
|
|
useEffect(() => {
|
|
setPendingData(null);
|
|
}, [formData]);
|
|
|
|
useEffect(() => {
|
|
if (isResettingRef.current) {
|
|
isResettingRef.current = false;
|
|
}
|
|
}, [formKey]);
|
|
|
|
const hasChanges = useMemo(() => {
|
|
if (!pendingData) return false;
|
|
return !isEqual(formData, pendingData);
|
|
}, [formData, pendingData]);
|
|
|
|
const handleChange = useCallback(
|
|
(data: unknown) => {
|
|
if (isResettingRef.current) {
|
|
setPendingData(null);
|
|
return;
|
|
}
|
|
if (!data || typeof data !== "object") {
|
|
setPendingData(null);
|
|
return;
|
|
}
|
|
if (isEqual(formData, data)) {
|
|
setPendingData(null);
|
|
return;
|
|
}
|
|
setPendingData(data);
|
|
},
|
|
[formData],
|
|
);
|
|
|
|
const handleReset = useCallback(() => {
|
|
isResettingRef.current = true;
|
|
setPendingData(null);
|
|
setFormKey((prev) => prev + 1);
|
|
}, []);
|
|
|
|
const handleSave = useCallback(async () => {
|
|
if (!pendingData) return;
|
|
|
|
setIsSaving(true);
|
|
try {
|
|
await axios.put("config/set", {
|
|
update_topic: `config/${sectionKey}`,
|
|
config_data: {
|
|
[sectionKey]: pendingData,
|
|
},
|
|
});
|
|
|
|
toast.success(
|
|
t("toast.success", {
|
|
ns: "views/settings",
|
|
defaultValue: "Settings saved successfully",
|
|
}),
|
|
);
|
|
|
|
setPendingData(null);
|
|
onSave();
|
|
} catch {
|
|
toast.error(
|
|
t("toast.error", {
|
|
ns: "views/settings",
|
|
defaultValue: "Failed to save settings",
|
|
}),
|
|
);
|
|
} finally {
|
|
setIsSaving(false);
|
|
}
|
|
}, [sectionKey, pendingData, t, onSave]);
|
|
|
|
if (!schema || !sectionConfig) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center gap-3">
|
|
<Heading as="h4">{title}</Heading>
|
|
{hasChanges && (
|
|
<Badge variant="outline" className="text-xs">
|
|
{t("modified", { ns: "common", defaultValue: "Modified" })}
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
<ConfigForm
|
|
key={formKey}
|
|
schema={schema}
|
|
formData={pendingData || formData}
|
|
onChange={handleChange}
|
|
fieldOrder={sectionConfig.fieldOrder}
|
|
hiddenFields={sectionConfig.hiddenFields}
|
|
advancedFields={sectionConfig.advancedFields}
|
|
liveValidate={sectionConfig.liveValidate}
|
|
showSubmit={false}
|
|
i18nNamespace={sectionConfig.i18nNamespace}
|
|
disabled={isSaving}
|
|
/>
|
|
|
|
<div className="flex items-center justify-between pt-2">
|
|
<div className="flex items-center gap-2">
|
|
{hasChanges && (
|
|
<span className="text-sm text-muted-foreground">
|
|
{t("unsavedChanges", {
|
|
ns: "views/settings",
|
|
defaultValue: "You have unsaved changes",
|
|
})}
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{hasChanges && (
|
|
<Button
|
|
onClick={handleReset}
|
|
variant="outline"
|
|
disabled={isSaving}
|
|
className="gap-2"
|
|
>
|
|
{t("reset", { ns: "common", defaultValue: "Reset" })}
|
|
</Button>
|
|
)}
|
|
<Button
|
|
onClick={handleSave}
|
|
disabled={!hasChanges || isSaving}
|
|
className="gap-2"
|
|
>
|
|
<LuSave className="h-4 w-4" />
|
|
{isSaving
|
|
? t("saving", { ns: "common", defaultValue: "Saving..." })
|
|
: t("save", { ns: "common", defaultValue: "Save" })}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function GlobalConfigView() {
|
|
const { t } = useTranslation([
|
|
"views/settings",
|
|
"config/detect",
|
|
"config/record",
|
|
"config/snapshots",
|
|
"config/motion",
|
|
"config/objects",
|
|
"config/review",
|
|
"config/audio",
|
|
"config/notifications",
|
|
"config/live",
|
|
"config/timestamp_style",
|
|
"config/mqtt",
|
|
"config/audio_transcription",
|
|
"config/database",
|
|
"config/auth",
|
|
"config/tls",
|
|
"config/networking",
|
|
"config/proxy",
|
|
"config/ui",
|
|
"config/logger",
|
|
"config/environment_vars",
|
|
"config/telemetry",
|
|
"config/birdseye",
|
|
"config/ffmpeg",
|
|
"config/detectors",
|
|
"config/model",
|
|
"config/genai",
|
|
"config/classification",
|
|
"config/semantic_search",
|
|
"config/face_recognition",
|
|
"config/lpr",
|
|
"config/go2rtc",
|
|
"config/camera_groups",
|
|
"config/safe_mode",
|
|
"config/version",
|
|
"common",
|
|
]);
|
|
const [activeTab, setActiveTab] = useState("shared");
|
|
const [activeSection, setActiveSection] = useState("detect");
|
|
|
|
const { data: config, mutate: refreshConfig } =
|
|
useSWR<FrigateConfig>("config");
|
|
const { data: schema } = useSWR<RJSFSchema>("config/schema.json");
|
|
|
|
const handleSave = useCallback(() => {
|
|
refreshConfig();
|
|
}, [refreshConfig]);
|
|
|
|
// Get the sections for the current tab
|
|
const currentSections = useMemo(() => {
|
|
if (activeTab === "shared") {
|
|
return sharedSections;
|
|
} else if (activeTab === "system") {
|
|
return systemSections.map((key) => ({
|
|
key,
|
|
i18nNamespace: globalSectionConfigs[key].i18nNamespace,
|
|
component: null, // Uses GlobalConfigSection instead
|
|
}));
|
|
} else {
|
|
return integrationSections.map((key) => ({
|
|
key,
|
|
i18nNamespace: globalSectionConfigs[key].i18nNamespace,
|
|
component: null,
|
|
}));
|
|
}
|
|
}, [activeTab]);
|
|
|
|
// Reset active section when tab changes
|
|
const handleTabChange = useCallback((tab: string) => {
|
|
setActiveTab(tab);
|
|
if (tab === "shared") {
|
|
setActiveSection("detect");
|
|
} else if (tab === "system") {
|
|
setActiveSection("database");
|
|
} else {
|
|
setActiveSection("mqtt");
|
|
}
|
|
}, []);
|
|
|
|
if (!config || !schema) {
|
|
return (
|
|
<div className="flex h-full items-center justify-center">
|
|
<ActivityIndicator />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="flex size-full flex-col">
|
|
<div className="mb-4">
|
|
<Heading as="h2">
|
|
{t("configForm.global.title", {
|
|
defaultValue: "Global Configuration",
|
|
})}
|
|
</Heading>
|
|
<p className="text-sm text-muted-foreground">
|
|
{t("configForm.global.description", {
|
|
defaultValue:
|
|
"Configure global settings that apply to all cameras by default.",
|
|
})}
|
|
</p>
|
|
</div>
|
|
|
|
<Tabs
|
|
value={activeTab}
|
|
onValueChange={handleTabChange}
|
|
className="flex flex-1 flex-col overflow-hidden"
|
|
>
|
|
<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>
|
|
|
|
<div className="mt-4 flex flex-1 gap-6 overflow-hidden">
|
|
{/* Section Navigation */}
|
|
<nav className="w-48 shrink-0">
|
|
<ul className="space-y-1">
|
|
{currentSections.map((section) => {
|
|
const sectionLabel = t("label", {
|
|
ns: section.i18nNamespace,
|
|
defaultValue:
|
|
section.key.charAt(0).toUpperCase() +
|
|
section.key.slice(1).replace(/_/g, " "),
|
|
});
|
|
|
|
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-left text-sm transition-colors",
|
|
activeSection === section.key
|
|
? "bg-accent text-accent-foreground"
|
|
: "hover:bg-muted",
|
|
)}
|
|
>
|
|
<span>{sectionLabel}</span>
|
|
</button>
|
|
</li>
|
|
);
|
|
})}
|
|
</ul>
|
|
</nav>
|
|
|
|
{/* Section Content */}
|
|
<div className="scrollbar-container flex-1 overflow-y-auto pr-4">
|
|
{activeTab === "shared" && (
|
|
<>
|
|
{sharedSections.map((section) => {
|
|
const SectionComponent = section.component;
|
|
return (
|
|
<div
|
|
key={section.key}
|
|
className={cn(
|
|
activeSection === section.key ? "block" : "hidden",
|
|
)}
|
|
>
|
|
<Heading as="h4" className="mb-4">
|
|
{t("label", {
|
|
ns: section.i18nNamespace,
|
|
defaultValue:
|
|
section.key.charAt(0).toUpperCase() +
|
|
section.key.slice(1).replace(/_/g, " "),
|
|
})}
|
|
</Heading>
|
|
<SectionComponent
|
|
level="global"
|
|
onSave={handleSave}
|
|
showTitle={false}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === "system" && (
|
|
<>
|
|
{systemSections.map((sectionKey) => {
|
|
const sectionTitle = t("label", {
|
|
ns: globalSectionConfigs[sectionKey].i18nNamespace,
|
|
defaultValue:
|
|
sectionKey.charAt(0).toUpperCase() +
|
|
sectionKey.slice(1).replace(/_/g, " "),
|
|
});
|
|
|
|
return (
|
|
<div
|
|
key={sectionKey}
|
|
className={cn(
|
|
activeSection === sectionKey ? "block" : "hidden",
|
|
)}
|
|
>
|
|
<GlobalConfigSection
|
|
sectionKey={sectionKey}
|
|
schema={extractSchemaSection(schema, sectionKey)}
|
|
config={config}
|
|
onSave={handleSave}
|
|
title={sectionTitle}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
|
|
{activeTab === "integrations" && (
|
|
<>
|
|
{integrationSections.map((sectionKey) => {
|
|
const sectionTitle = t("label", {
|
|
ns: globalSectionConfigs[sectionKey].i18nNamespace,
|
|
defaultValue:
|
|
sectionKey.charAt(0).toUpperCase() +
|
|
sectionKey.slice(1).replace(/_/g, " "),
|
|
});
|
|
|
|
return (
|
|
<div
|
|
key={sectionKey}
|
|
className={cn(
|
|
activeSection === sectionKey ? "block" : "hidden",
|
|
)}
|
|
>
|
|
<GlobalConfigSection
|
|
sectionKey={sectionKey}
|
|
schema={extractSchemaSection(schema, sectionKey)}
|
|
config={config}
|
|
onSave={handleSave}
|
|
title={sectionTitle}
|
|
/>
|
|
</div>
|
|
);
|
|
})}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Tabs>
|
|
</div>
|
|
);
|
|
}
|