diff --git a/src/components/configuration/ConfigDiffPage.tsx b/src/components/configuration/ConfigDiffPage.tsx new file mode 100644 index 0000000..3685080 --- /dev/null +++ b/src/components/configuration/ConfigDiffPage.tsx @@ -0,0 +1,685 @@ +import yaml from 'js-yaml'; +import { Link } from '@tanstack/react-router'; +import { Badge, Button, Icon } from '@clickhouse/click-ui'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { useState, useMemo, useRef, useCallback, useEffect } from 'react'; +import type * as t from '@/types'; +import { baseConfigOptions, parseConfigYamlFn, importBaseConfigFn } from '@/server'; +import { + computeConfigDrift, + buildMergedConfig, + buildConfiguredValues, + normalizeImportConfig, + cn, +} from '@/utils'; +import { useLocalize, useCapabilities } from '@/hooks'; +import { SystemCapabilities } from '@/constants'; + +function formatValue(value: t.ConfigValue): string { + if (value === undefined || value === null) return '—'; + if (typeof value === 'boolean') return value ? 'true' : 'false'; + if (typeof value === 'number' || typeof value === 'string') return String(value); + try { + return yaml.dump(value, { lineWidth: -1 }).trimEnd(); + } catch { + return String(value); + } +} + +export function ConfigDiffPage() { + const localize = useLocalize(); + const queryClient = useQueryClient(); + const { hasCapability } = useCapabilities(); + const canManageConfig = hasCapability(SystemCapabilities.MANAGE_CONFIGS); + const { data: baseConfigData } = useQuery(baseConfigOptions); + + const [yamlText, setYamlText] = useState(''); + const [fileName, setFileName] = useState(); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(); + const [validationErrors, setValidationErrors] = useState(); + const [result, setResult] = useState(null); + const [parsedConfig, setParsedConfig] = useState | null>(null); + const [resolutions, setResolutions] = useState({}); + const [copied, setCopied] = useState(false); + const [applying, setApplying] = useState(false); + const [applyResult, setApplyResult] = useState<{ ok: boolean; message: string } | null>(null); + + const fileInputRef = useRef(null); + const copyTimer = useRef | undefined>(undefined); + useEffect(() => () => clearTimeout(copyTimer.current), []); + + const configuredValues = useMemo( + () => + buildConfiguredValues( + baseConfigData?.config ?? null, + baseConfigData?.dbOverrides, + baseConfigData?.configuredFromBase, + ), + [baseConfigData], + ); + + const merged = useMemo(() => { + if (!result || !parsedConfig) return null; + return buildMergedConfig(parsedConfig, result.additions, result.conflicts, resolutions); + }, [result, parsedConfig, resolutions]); + + const mergedYaml = useMemo(() => { + if (!merged) return ''; + return yaml.dump(merged, { lineWidth: -1, sortKeys: true }).trimEnd(); + }, [merged]); + + const resolvedCount = useMemo(() => { + if (!result) return 0; + return result.conflicts.filter((c) => resolutions[c.path]).length; + }, [result, resolutions]); + + const resetResult = useCallback(() => { + setError(undefined); + setValidationErrors(undefined); + setResult(null); + setParsedConfig(null); + setResolutions({}); + setApplyResult(null); + }, []); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + setFileName(file.name); + resetResult(); + const reader = new FileReader(); + reader.onload = (ev) => { + const text = ev.target?.result; + if (typeof text === 'string') setYamlText(text); + }; + reader.onerror = () => setError(localize('com_config_import_error')); + reader.readAsText(file); + }; + + const handleCompare = useCallback(async () => { + const trimmed = yamlText.trim(); + if (!trimmed) return; + + setLoading(true); + resetResult(); + + try { + const parsed = await parseConfigYamlFn({ data: { yamlContent: trimmed } }); + if (!parsed.success || !parsed.config) { + setError(parsed.error ?? localize('com_config_import_error')); + if (parsed.validationErrors) { + setValidationErrors(parsed.validationErrors as t.ImportValidationError[]); + } + return; + } + setParsedConfig(parsed.config); + setResult(computeConfigDrift(parsed.config, configuredValues)); + } catch (err) { + setError(err instanceof Error ? err.message : localize('com_config_import_error')); + } finally { + setLoading(false); + } + }, [yamlText, configuredValues, localize, resetResult]); + + const handleResolve = useCallback((path: string, side: t.ConflictSide) => { + setResolutions((prev) => ({ ...prev, [path]: side })); + setApplyResult(null); + }, []); + + const handleResolveAll = useCallback( + (side: t.ConflictSide) => { + if (!result) return; + setResolutions((prev) => { + const next = { ...prev }; + for (const c of result.conflicts) next[c.path] = side; + return next; + }); + setApplyResult(null); + }, + [result], + ); + + const handleCopy = useCallback(async () => { + if (!mergedYaml) return; + try { + await navigator.clipboard.writeText(mergedYaml); + setCopied(true); + clearTimeout(copyTimer.current); + copyTimer.current = setTimeout(() => setCopied(false), 2000); + } catch { + setError(localize('com_config_drift_copy_failed')); + } + }, [mergedYaml, localize]); + + const handleDownload = useCallback(() => { + if (!mergedYaml) return; + const blob = new Blob([`${mergedYaml}\n`], { type: 'application/x-yaml' }); + const url = URL.createObjectURL(blob); + const anchor = document.createElement('a'); + anchor.href = url; + anchor.download = 'librechat.yaml'; + document.body.appendChild(anchor); + anchor.click(); + anchor.remove(); + URL.revokeObjectURL(url); + }, [mergedYaml]); + + const handleApply = useCallback(async () => { + if (!merged) return; + if (!window.confirm(localize('com_config_drift_apply_confirm'))) return; + setApplying(true); + setApplyResult(null); + try { + await importBaseConfigFn({ data: { config: normalizeImportConfig(merged) } }); + await queryClient.invalidateQueries({ queryKey: ['baseConfig'] }); + setApplyResult({ ok: true, message: localize('com_config_drift_apply_success') }); + } catch (err) { + setApplyResult({ + ok: false, + message: err instanceof Error ? err.message : localize('com_config_drift_apply_error'), + }); + } finally { + setApplying(false); + } + }, [merged, localize, queryClient]); + + const hasContent = yamlText.trim().length > 0; + + return ( +
+
+ + + {localize('com_config_drift_back')} + + +

+ {localize('com_config_drift_title')} +

+

+ {localize('com_config_drift_desc')} +

+ +
+
+
+ + {localize('com_config_drift_input_label')} + + + +
+ +