import useSWR from "swr"; import * as monaco from "monaco-editor"; import { configureMonacoYaml } from "monaco-yaml"; import { useCallback, useEffect, useRef, useState } from "react"; import { useApiHost } from "@/api"; import Heading from "@/components/ui/heading"; import ActivityIndicator from "@/components/indicators/activity-indicator"; import { Button } from "@/components/ui/button"; import axios, { AxiosError } from "axios"; import copy from "copy-to-clipboard"; import { useTheme } from "@/context/theme-provider"; import { Toaster } from "@/components/ui/sonner"; import { toast } from "sonner"; import { LuCopy, LuSave } from "react-icons/lu"; import { MdOutlineRestartAlt } from "react-icons/md"; import RestartDialog from "@/components/overlay/dialog/RestartDialog"; import { useTranslation } from "react-i18next"; import { useRestart } from "@/api/ws"; import { useResizeObserver } from "@/hooks/resize-observer"; import { FrigateConfig } from "@/types/frigateConfig"; type SaveOptions = "saveonly" | "restart"; type ApiErrorResponse = { message?: string; detail?: string; }; function ConfigEditor() { const { t } = useTranslation(["views/configEditor"]); const apiHost = useApiHost(); useEffect(() => { document.title = t("documentTitle"); }, [t]); const { data: config } = useSWR("config", { revalidateOnFocus: false, }); const { data: rawConfig } = useSWR("config/raw"); const { theme, systemTheme } = useTheme(); const [error, setError] = useState(); const editorRef = useRef(null); const modelRef = useRef(null); const configRef = useRef(null); const schemaConfiguredRef = useRef(false); const [restartDialogOpen, setRestartDialogOpen] = useState(false); const { send: sendRestart } = useRestart(); const initialValidationRef = useRef(false); const onHandleSaveConfig = useCallback( async (save_option: SaveOptions): Promise => { if (!editorRef.current) { return; } try { const response = await axios.post( `config/save?save_option=${save_option}`, editorRef.current.getValue(), { headers: { "Content-Type": "text/plain" }, }, ); if (response.status === 200) { setError(""); setHasChanges(false); toast.success(response.data.message, { position: "top-center" }); } } catch (error) { toast.error(t("toast.error.savingError"), { position: "top-center" }); const axiosError = error as AxiosError; const errorMessage = axiosError.response?.data?.message || axiosError.response?.data?.detail || "Unknown error"; setError(errorMessage); throw new Error(errorMessage); } }, [editorRef, t], ); const handleCopyConfig = useCallback(async () => { if (!editorRef.current) { return; } copy(editorRef.current.getValue()); toast.success(t("toast.success.copyToClipboard"), { position: "top-center", }); }, [editorRef, t]); const handleSaveAndRestart = useCallback(async () => { try { await onHandleSaveConfig("saveonly"); setRestartDialogOpen(true); } catch (error) { // If save fails, error is already set in onHandleSaveConfig, no dialog opens } }, [onHandleSaveConfig]); useEffect(() => { if (!rawConfig) { return; } const modelUri = monaco.Uri.parse( `a://b/api/config/schema_${Date.now()}.json`, ); // Configure Monaco YAML schema only once if (!schemaConfiguredRef.current) { configureMonacoYaml(monaco, { enableSchemaRequest: true, hover: true, completion: true, validate: true, format: true, schemas: [ { uri: `${apiHost}api/config/schema.json`, fileMatch: [String(modelUri)], }, ], }); schemaConfiguredRef.current = true; } if (!modelRef.current) { modelRef.current = monaco.editor.createModel(rawConfig, "yaml", modelUri); } else { modelRef.current.setValue(rawConfig); } const container = configRef.current; if (container && !editorRef.current) { editorRef.current = monaco.editor.create(container, { language: "yaml", model: modelRef.current, scrollBeyondLastLine: false, theme: (systemTheme || theme) == "dark" ? "vs-dark" : "vs-light", }); editorRef.current?.addCommand( monaco.KeyMod.CtrlCmd | monaco.KeyCode.KeyS, () => { onHandleSaveConfig("saveonly"); }, ); } else if (editorRef.current) { editorRef.current.setModel(modelRef.current); } return () => { if (editorRef.current) { editorRef.current.dispose(); editorRef.current = null; } if (modelRef.current) { modelRef.current.dispose(); modelRef.current = null; } schemaConfiguredRef.current = false; }; }, [rawConfig, apiHost, systemTheme, theme, onHandleSaveConfig]); // when in safe mode, attempt to validate the existing (invalid) config immediately // so that the user sees the validation errors without needing to press save useEffect(() => { if ( config?.safe_mode && rawConfig && !initialValidationRef.current && !error ) { initialValidationRef.current = true; axios .post(`config/save?save_option=saveonly`, rawConfig, { headers: { "Content-Type": "text/plain" }, }) .then(() => { // if this succeeds while in safe mode, we won't force any UI change }) .catch((e: AxiosError) => { const errorMessage = e.response?.data?.message || e.response?.data?.detail || "Unknown error"; setError(errorMessage); }); } }, [config?.safe_mode, rawConfig, error]); // monitoring state const [hasChanges, setHasChanges] = useState(false); useEffect(() => { if (!rawConfig || !modelRef.current) { return; } modelRef.current.onDidChangeContent(() => { if (modelRef.current?.getValue() != rawConfig) { setHasChanges(true); } else { setHasChanges(false); } }); }, [rawConfig]); useEffect(() => { if (rawConfig && modelRef.current) { modelRef.current.setValue(rawConfig); setHasChanges(false); } }, [rawConfig]); useEffect(() => { let listener: ((e: BeforeUnloadEvent) => void) | undefined; if (hasChanges) { listener = (e) => { e.preventDefault(); e.returnValue = true; return t("confirm"); }; window.addEventListener("beforeunload", listener); } return () => { if (listener) { window.removeEventListener("beforeunload", listener); } }; }, [hasChanges, t]); // layout change handler const [{ width, height }] = useResizeObserver(configRef); useEffect(() => { if (editorRef.current) { // Small delay to ensure DOM has updated const timeoutId = setTimeout(() => { editorRef.current?.layout(); }, 0); return () => clearTimeout(timeoutId); } }, [error, width, height]); if (!rawConfig) { return ; } return (
{t(config?.safe_mode ? "safeConfigEditor" : "configEditor")} {config?.safe_mode && (
{t("safeModeDescription")}
)}
{error && (
{error}
)}
setRestartDialogOpen(false)} onRestart={() => sendRestart("restart")} />
); } export default ConfigEditor;