| 1 | /** |
| 2 | * ConfigManagerDialog - 全局配置管理弹框 |
| 3 | * 将后端配置对象转换为可编辑表单,并支持保存后重启与恢复检查。 |
| 4 | */ |
| 5 | 'use client' |
| 6 | |
| 7 | import type { ConfigEditorStatus, ConfigPath, ConfigPathFocusRequest, ConfigValue } from './types' |
| 8 | import { AlertCircle, Bot, Braces, CheckCircle2, FileSliders, Loader2, RefreshCw, RotateCcw, Save, Server, ShieldCheck, SlidersHorizontal } from 'lucide-react' |
| 9 | import { useCallback, useEffect, useMemo, useRef, useState } from 'react' |
| 10 | import { |
| 11 | checkConfigEditorConfigReadyApi, |
| 12 | getConfigEditorConfigApi, |
| 13 | restartConfigEditorServiceApi, |
| 14 | saveConfigEditorConfigApi, |
| 15 | validateConfigEditorConfigApi, |
| 16 | } from '@/api/config-editor/config-editor.api' |
| 17 | import { ConfigEditorServiceTarget } from '@/api/config-editor/config-editor.types' |
| 18 | import { useTransClient } from '@/app/i18n/client' |
| 19 | import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert' |
| 20 | import { Badge } from '@/components/ui/badge' |
| 21 | import { Button } from '@/components/ui/button' |
| 22 | import { Dialog, DialogContent, DialogDescription, DialogTitle } from '@/components/ui/dialog' |
| 23 | import { Skeleton } from '@/components/ui/skeleton' |
| 24 | import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs' |
| 25 | import { toast } from '@/utils/ui/toast' |
| 26 | import { ConfigFormPanel } from './components/ConfigFormPanel' |
| 27 | import { ConfigJsonPanel } from './components/ConfigJsonPanel' |
| 28 | import { ConfigSectionNav } from './components/ConfigSectionNav' |
| 29 | import { useConfigSectionSpy } from './hooks/useConfigSectionSpy' |
| 30 | import { getValueAtPath, isRecord, joinPath, setValueAtPath, stableStringify } from './utils/configPath' |
| 31 | import { buildConfigSections } from './utils/configSections' |
| 32 | |
| 33 | export interface ConfigManagerDialogProps { |
| 34 | open: boolean |
| 35 | onClose: () => void |
| 36 | } |
| 37 | |
| 38 | type LoadingAction = 'load' | 'validate' | 'save' | 'saveRestart' | 'restart' |
| 39 | type ConfigEditMode = 'visual' | 'json' |
| 40 | |
| 41 | interface ConfigManagerError { |
| 42 | title: string |
| 43 | description?: string |
| 44 | } |
| 45 | |
| 46 | const healthCheckIntervalMs = 1600 |
| 47 | const healthCheckMaxAttempts = 75 |
| 48 | const serverRelayPath: ConfigPath = ['relay'] |
| 49 | const aiRelayPath: ConfigPath = ['ai', 'relay'] |
| 50 | const serverRelayDefaultConfig: Record<string, unknown> = { |
| 51 | serverUrl: '', |
| 52 | apiKey: '', |
| 53 | callbackUrl: '', |
| 54 | } |
| 55 | const aiRelayDefaultConfig: Record<string, unknown> = { |
| 56 | url: '', |
| 57 | apiKey: '', |
| 58 | timeout: 300000, |
| 59 | } |
| 60 | |
| 61 | function isConfigEditorServiceTarget(value: string): value is ConfigEditorServiceTarget { |
| 62 | return value === ConfigEditorServiceTarget.Server || value === ConfigEditorServiceTarget.Ai |
| 63 | } |
| 64 | |
| 65 | function getErrorMessage(error: unknown) { |
| 66 | return error instanceof Error ? error.message : String(error) |
| 67 | } |
| 68 | |
| 69 | function getResponseMessage(response: { message?: string } | null | undefined) { |
| 70 | return response?.message?.trim() || '' |
| 71 | } |
| 72 | |
| 73 | function formatJsonConfig(config: Record<string, unknown>) { |
| 74 | return JSON.stringify(config, null, 2) |
| 75 | } |
| 76 | |
| 77 | function getRelayPath(serviceTarget: ConfigEditorServiceTarget): ConfigPath { |
| 78 | return serviceTarget === ConfigEditorServiceTarget.Ai ? aiRelayPath : serverRelayPath |
| 79 | } |
| 80 | |
| 81 | function getRelayDefaultConfig(serviceTarget: ConfigEditorServiceTarget) { |
| 82 | return serviceTarget === ConfigEditorServiceTarget.Ai |
| 83 | ? { ...aiRelayDefaultConfig } |
| 84 | : { ...serverRelayDefaultConfig } |
| 85 | } |
| 86 | |
| 87 | function ensureRelayConfig(config: Record<string, unknown>, serviceTarget: ConfigEditorServiceTarget) { |
| 88 | const relayPath = getRelayPath(serviceTarget) |
| 89 | if (getValueAtPath(config, relayPath) !== undefined) { |
| 90 | return { config, insertedRelayPath: null } |
| 91 | } |
| 92 | |
| 93 | if (serviceTarget === ConfigEditorServiceTarget.Ai) { |
| 94 | const aiConfig = config.ai |
| 95 | if (!isRecord(aiConfig)) { |
| 96 | return { config, insertedRelayPath: null } |
| 97 | } |
| 98 | |
| 99 | return { |
| 100 | config: { |
| 101 | ...config, |
| 102 | ai: { |
| 103 | ...aiConfig, |
| 104 | relay: getRelayDefaultConfig(serviceTarget), |
| 105 | }, |
| 106 | }, |
| 107 | insertedRelayPath: relayPath, |
| 108 | } |
| 109 | } |
| 110 | |
| 111 | return { |
| 112 | config: { |
| 113 | ...config, |
| 114 | relay: getRelayDefaultConfig(serviceTarget), |
| 115 | }, |
| 116 | insertedRelayPath: relayPath, |
| 117 | } |
| 118 | } |
| 119 | |
| 120 | function removeValueAtPath(source: Record<string, unknown>, path: ConfigPath): Record<string, unknown> { |
| 121 | const [segment, ...remainingPath] = path |
| 122 | if (typeof segment !== 'string') |
| 123 | return source |
| 124 | |
| 125 | const nextSource = { ...source } |
| 126 | if (remainingPath.length === 0) { |
| 127 | delete nextSource[segment] |
| 128 | return nextSource |
| 129 | } |
| 130 | |
| 131 | const nestedValue = nextSource[segment] |
| 132 | if (!isRecord(nestedValue)) |
| 133 | return nextSource |
| 134 | |
| 135 | nextSource[segment] = removeValueAtPath(nestedValue, remainingPath) |
| 136 | return nextSource |
| 137 | } |
| 138 | |
| 139 | function stripInsertedRelayPlaceholder( |
| 140 | config: Record<string, unknown>, |
| 141 | insertedRelayPath: ConfigPath | null, |
| 142 | serviceTarget: ConfigEditorServiceTarget, |
| 143 | ) { |
| 144 | if (!insertedRelayPath) |
| 145 | return config |
| 146 | |
| 147 | const relayValue = getValueAtPath(config, insertedRelayPath) |
| 148 | if (stableStringify(relayValue) !== stableStringify(getRelayDefaultConfig(serviceTarget))) |
| 149 | return config |
| 150 | |
| 151 | return removeValueAtPath(config, insertedRelayPath) |
| 152 | } |
| 153 | |
| 154 | function StatusBadge({ status }: { status: ConfigEditorStatus }) { |
| 155 | const { t } = useTransClient('configManager') |
| 156 | |
| 157 | if (status.service === 'restarting') { |
| 158 | return ( |
| 159 | <Badge variant="outline" className="gap-1 border-warning/30 bg-warning/10 text-warning"> |
| 160 | <Loader2 className="h-3 w-3 animate-spin" /> |
| 161 | {t('status.restarting')} |
| 162 | </Badge> |
| 163 | ) |
| 164 | } |
| 165 | |
| 166 | if (status.service === 'failed') { |
| 167 | return ( |
| 168 | <Badge variant="outline" className="gap-1 border-destructive/30 bg-destructive/10 text-destructive"> |
| 169 | <AlertCircle className="h-3 w-3" /> |
| 170 | {t('status.failed')} |
| 171 | </Badge> |
| 172 | ) |
| 173 | } |
| 174 | |
| 175 | if (status.service === 'running') { |
| 176 | return ( |
| 177 | <Badge variant="outline" className="gap-1 border-success/30 bg-success/10 text-success"> |
| 178 | <CheckCircle2 className="h-3 w-3" /> |
| 179 | {t('status.running')} |
| 180 | </Badge> |
| 181 | ) |
| 182 | } |
| 183 | |
| 184 | return <Badge variant="secondary">{t('status.unknown')}</Badge> |
| 185 | } |
| 186 | |
| 187 | function LoadingSkeleton() { |
| 188 | return ( |
| 189 | <div className="flex h-full"> |
| 190 | <div className="hidden w-64 shrink-0 border-r border-border p-3 md:block"> |
| 191 | {Array.from({ length: 8 }).map((_, index) => <Skeleton key={index} className="mb-2 h-12" />)} |
| 192 | </div> |
| 193 | <div className="flex-1 p-4"> |
| 194 | {Array.from({ length: 4 }).map((_, index) => ( |
| 195 | <div key={index} className="mb-4 rounded-2xl border border-border p-4"> |
| 196 | <Skeleton className="mb-4 h-5 w-40" /> |
| 197 | <Skeleton className="mb-2 h-10" /> |
| 198 | <Skeleton className="mb-2 h-10" /> |
| 199 | <Skeleton className="h-10" /> |
| 200 | </div> |
| 201 | ))} |
| 202 | </div> |
| 203 | </div> |
| 204 | ) |
| 205 | } |
| 206 | |
| 207 | export function ConfigManagerDialog({ open, onClose }: ConfigManagerDialogProps) { |
| 208 | const { t } = useTransClient('configManager') |
| 209 | const [config, setConfig] = useState<Record<string, unknown> | null>(null) |
| 210 | const [originalConfig, setOriginalConfig] = useState<Record<string, unknown> | null>(null) |
| 211 | const [format, setFormat] = useState<ConfigEditorStatus['format']>() |
| 212 | const [loadingAction, setLoadingAction] = useState<LoadingAction | null>(null) |
| 213 | const [error, setError] = useState<ConfigManagerError | null>(null) |
| 214 | const [successMessage, setSuccessMessage] = useState('') |
| 215 | const [healthAttempts, setHealthAttempts] = useState(0) |
| 216 | const [serviceStatus, setServiceStatus] = useState<ConfigEditorStatus['service']>('unknown') |
| 217 | const [serviceTarget, setServiceTarget] = useState(ConfigEditorServiceTarget.Server) |
| 218 | const [insertedRelayPath, setInsertedRelayPath] = useState<ConfigPath | null>(null) |
| 219 | const [editMode, setEditMode] = useState<ConfigEditMode>('visual') |
| 220 | const [jsonText, setJsonText] = useState('') |
| 221 | const [visualFocusRequest, setVisualFocusRequest] = useState<ConfigPathFocusRequest | null>(null) |
| 222 | const [jsonFocusRequest, setJsonFocusRequest] = useState<ConfigPathFocusRequest | null>(null) |
| 223 | const [highlightedVisualPathKey, setHighlightedVisualPathKey] = useState('') |
| 224 | const [highlightedJsonPathKey, setHighlightedJsonPathKey] = useState('') |
| 225 | const [jsonScrollTop, setJsonScrollTop] = useState(0) |
| 226 | const scrollContainerRef = useRef<HTMLDivElement | null>(null) |
| 227 | const visualScrollTopRef = useRef(0) |
| 228 | const focusRequestIdRef = useRef(0) |
| 229 | const visualHighlightTimerRef = useRef<number | null>(null) |
| 230 | const jsonHighlightTimerRef = useRef<number | null>(null) |
| 231 | |
| 232 | const dirty = useMemo(() => { |
| 233 | if (!config || !originalConfig) |
| 234 | return false |
| 235 | if (editMode === 'json') |
| 236 | return jsonText.trim() !== formatJsonConfig(originalConfig) |
| 237 | return stableStringify(config) !== stableStringify(originalConfig) |
| 238 | }, [config, editMode, jsonText, originalConfig]) |
| 239 | |
| 240 | const sections = useMemo( |
| 241 | () => config ? buildConfigSections(config, originalConfig, serviceTarget, t) : [], |
| 242 | [config, originalConfig, serviceTarget, t], |
| 243 | ) |
| 244 | const { activeSectionId, scrollToSection } = useConfigSectionSpy(scrollContainerRef, sections) |
| 245 | const disabled = !!loadingAction || serviceStatus === 'restarting' |
| 246 | const serverServiceDisabled = disabled || (dirty && serviceTarget !== ConfigEditorServiceTarget.Server) |
| 247 | const aiServiceDisabled = disabled || (dirty && serviceTarget !== ConfigEditorServiceTarget.Ai) |
| 248 | |
| 249 | const clearVisualHighlightLater = useCallback(() => { |
| 250 | if (visualHighlightTimerRef.current !== null) |
| 251 | window.clearTimeout(visualHighlightTimerRef.current) |
| 252 | visualHighlightTimerRef.current = window.setTimeout(() => setHighlightedVisualPathKey(''), 1800) |
| 253 | }, []) |
| 254 | |
| 255 | const clearJsonHighlightLater = useCallback(() => { |
| 256 | if (jsonHighlightTimerRef.current !== null) |
| 257 | window.clearTimeout(jsonHighlightTimerRef.current) |
| 258 | jsonHighlightTimerRef.current = window.setTimeout(() => setHighlightedJsonPathKey(''), 1800) |
| 259 | }, []) |
| 260 | |
| 261 | useEffect(() => { |
| 262 | return () => { |
| 263 | if (visualHighlightTimerRef.current !== null) |
| 264 | window.clearTimeout(visualHighlightTimerRef.current) |
| 265 | if (jsonHighlightTimerRef.current !== null) |
| 266 | window.clearTimeout(jsonHighlightTimerRef.current) |
| 267 | } |
| 268 | }, []) |
| 269 | |
| 270 | const loadConfig = useCallback(async () => { |
| 271 | setLoadingAction('load') |
| 272 | setError(null) |
| 273 | setSuccessMessage('') |
| 274 | |
| 275 | try { |
| 276 | const response = await getConfigEditorConfigApi(serviceTarget, true) |
| 277 | if (!response || response.code !== 0 || !response.data?.config) { |
| 278 | throw new Error(getResponseMessage(response) || t('errors.loadFailed')) |
| 279 | } |
| 280 | |
| 281 | const normalizedConfig = ensureRelayConfig(response.data.config, serviceTarget) |
| 282 | setConfig(normalizedConfig.config) |
| 283 | setOriginalConfig(normalizedConfig.config) |
| 284 | setJsonText(formatJsonConfig(normalizedConfig.config)) |
| 285 | setVisualFocusRequest(null) |
| 286 | setJsonFocusRequest(null) |
| 287 | setHighlightedVisualPathKey('') |
| 288 | setHighlightedJsonPathKey('') |
| 289 | visualScrollTopRef.current = 0 |
| 290 | setJsonScrollTop(0) |
| 291 | setInsertedRelayPath(normalizedConfig.insertedRelayPath) |
| 292 | setFormat(response.data.format) |
| 293 | setServiceStatus('running') |
| 294 | setHealthAttempts(0) |
| 295 | } |
| 296 | catch (loadError) { |
| 297 | setServiceStatus('failed') |
| 298 | setError({ title: t('errors.loadFailed'), description: getErrorMessage(loadError) }) |
| 299 | } |
| 300 | finally { |
| 301 | setLoadingAction(null) |
| 302 | } |
| 303 | }, [serviceTarget, t]) |
| 304 | |
| 305 | useEffect(() => { |
| 306 | if (!open) |
| 307 | return |
| 308 | loadConfig() |
| 309 | }, [open, loadConfig]) |
| 310 | |
| 311 | const handleServiceTargetChange = useCallback((value: string) => { |
| 312 | if (!isConfigEditorServiceTarget(value)) |
| 313 | return |
| 314 | |
| 315 | setServiceTarget(value) |
| 316 | setLoadingAction('load') |
| 317 | setConfig(null) |
| 318 | setOriginalConfig(null) |
| 319 | setJsonText('') |
| 320 | setInsertedRelayPath(null) |
| 321 | setFormat(undefined) |
| 322 | setHealthAttempts(0) |
| 323 | setServiceStatus('unknown') |
| 324 | setEditMode('visual') |
| 325 | setVisualFocusRequest(null) |
| 326 | setJsonFocusRequest(null) |
| 327 | setHighlightedVisualPathKey('') |
| 328 | setHighlightedJsonPathKey('') |
| 329 | visualScrollTopRef.current = 0 |
| 330 | setJsonScrollTop(0) |
| 331 | setError(null) |
| 332 | setSuccessMessage('') |
| 333 | }, []) |
| 334 | |
| 335 | const handleValueChange = useCallback((path: ConfigPath, value: ConfigValue) => { |
| 336 | setConfig((current) => { |
| 337 | if (!current) |
| 338 | return current |
| 339 | const nextConfig = setValueAtPath(current, path, value) |
| 340 | setJsonText(formatJsonConfig(nextConfig)) |
| 341 | return nextConfig |
| 342 | }) |
| 343 | setError(null) |
| 344 | setSuccessMessage('') |
| 345 | }, []) |
| 346 | |
| 347 | const parseJsonText = useCallback(() => { |
| 348 | try { |
| 349 | const parsed = JSON.parse(jsonText) as unknown |
| 350 | if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { |
| 351 | setError({ title: t('errors.jsonInvalid'), description: t('errors.jsonRootObject') }) |
| 352 | return null |
| 353 | } |
| 354 | |
| 355 | return parsed as Record<string, unknown> |
| 356 | } |
| 357 | catch (jsonError) { |
| 358 | setError({ title: t('errors.jsonInvalid'), description: getErrorMessage(jsonError) }) |
| 359 | return null |
| 360 | } |
| 361 | }, [jsonText, t]) |
| 362 | |
| 363 | const handleVisualScrollTopChange = useCallback((scrollTop: number) => { |
| 364 | visualScrollTopRef.current = scrollTop |
| 365 | }, []) |
| 366 | |
| 367 | const rememberVisualScrollTop = useCallback(() => { |
| 368 | const currentScrollTop = scrollContainerRef.current?.scrollTop |
| 369 | if (typeof currentScrollTop === 'number') |
| 370 | visualScrollTopRef.current = currentScrollTop |
| 371 | }, []) |
| 372 | |
| 373 | const handleEditModeChange = useCallback((value: string) => { |
| 374 | if (value !== 'visual' && value !== 'json') |
| 375 | return |
| 376 | |
| 377 | if (value === 'json' && editMode === 'visual') |
| 378 | rememberVisualScrollTop() |
| 379 | |
| 380 | if (value === 'visual' && editMode === 'json') { |
| 381 | const parsedConfig = parseJsonText() |
| 382 | if (!parsedConfig) |
| 383 | return |
| 384 | setConfig(parsedConfig) |
| 385 | setJsonText(formatJsonConfig(parsedConfig)) |
| 386 | } |
| 387 | |
| 388 | if (value === 'json' && config) |
| 389 | setJsonText(formatJsonConfig(config)) |
| 390 | |
| 391 | setVisualFocusRequest(null) |
| 392 | setJsonFocusRequest(null) |
| 393 | setHighlightedVisualPathKey('') |
| 394 | setHighlightedJsonPathKey('') |
| 395 | setError(null) |
| 396 | setSuccessMessage('') |
| 397 | setEditMode(value) |
| 398 | }, [config, editMode, parseJsonText, rememberVisualScrollTop]) |
| 399 | |
| 400 | const handleNavigateToJson = useCallback((path: ConfigPath) => { |
| 401 | rememberVisualScrollTop() |
| 402 | |
| 403 | if (config) |
| 404 | setJsonText(formatJsonConfig(config)) |
| 405 | |
| 406 | focusRequestIdRef.current += 1 |
| 407 | setJsonFocusRequest({ id: focusRequestIdRef.current, path: [...path] }) |
| 408 | setHighlightedJsonPathKey(joinPath(path)) |
| 409 | clearJsonHighlightLater() |
| 410 | setError(null) |
| 411 | setSuccessMessage('') |
| 412 | setEditMode('json') |
| 413 | }, [clearJsonHighlightLater, config, rememberVisualScrollTop]) |
| 414 | |
| 415 | const handleNavigateToVisual = useCallback((path: ConfigPath) => { |
| 416 | const nextConfig = editMode === 'json' ? parseJsonText() : config |
| 417 | if (!nextConfig) |
| 418 | return |
| 419 | |
| 420 | if (editMode === 'json') { |
| 421 | setConfig(nextConfig) |
| 422 | setJsonText(formatJsonConfig(nextConfig)) |
| 423 | } |
| 424 | |
| 425 | focusRequestIdRef.current += 1 |
| 426 | setVisualFocusRequest({ id: focusRequestIdRef.current, path: [...path] }) |
| 427 | setHighlightedVisualPathKey(joinPath(path)) |
| 428 | setJsonFocusRequest(null) |
| 429 | setHighlightedJsonPathKey('') |
| 430 | clearVisualHighlightLater() |
| 431 | setError(null) |
| 432 | setSuccessMessage('') |
| 433 | setEditMode('visual') |
| 434 | }, [clearVisualHighlightLater, config, editMode, parseJsonText]) |
| 435 | |
| 436 | const handleJsonTextChange = useCallback((value: string) => { |
| 437 | setJsonText(value) |
| 438 | setError(null) |
| 439 | setSuccessMessage('') |
| 440 | }, []) |
| 441 | |
| 442 | const handleJsonFocusRequestHandled = useCallback((requestId: number) => { |
| 443 | setJsonFocusRequest(current => current?.id === requestId ? null : current) |
| 444 | }, []) |
| 445 | |
| 446 | const handleVisualFocusRequestHandled = useCallback((requestId: number) => { |
| 447 | setVisualFocusRequest(current => current?.id === requestId ? null : current) |
| 448 | }, []) |
| 449 | |
| 450 | const getEditableConfig = useCallback(() => { |
| 451 | if (editMode === 'visual') |
| 452 | return config |
| 453 | |
| 454 | const parsedConfig = parseJsonText() |
| 455 | if (parsedConfig) |
| 456 | setConfig(parsedConfig) |
| 457 | return parsedConfig |
| 458 | }, [config, editMode, parseJsonText]) |
| 459 | |
| 460 | const validateConfig = useCallback(async (action: LoadingAction = 'validate', configOverride?: Record<string, unknown>) => { |
| 461 | const editableConfig = configOverride ?? getEditableConfig() |
| 462 | if (!editableConfig) |
| 463 | return false |
| 464 | const submittableConfig = stripInsertedRelayPlaceholder(editableConfig, insertedRelayPath, serviceTarget) |
| 465 | |
| 466 | setLoadingAction(action) |
| 467 | setError(null) |
| 468 | setSuccessMessage('') |
| 469 | |
| 470 | try { |
| 471 | const response = await validateConfigEditorConfigApi({ config: submittableConfig }, serviceTarget, true) |
| 472 | if (!response || response.code !== 0) { |
| 473 | throw new Error(getResponseMessage(response) || t('errors.validateFailed')) |
| 474 | } |
| 475 | if (action === 'validate') { |
| 476 | setSuccessMessage(t('messages.validateSuccess')) |
| 477 | toast.success(t('messages.validateSuccess')) |
| 478 | } |
| 479 | return true |
| 480 | } |
| 481 | catch (validateError) { |
| 482 | setError({ title: t('errors.validateFailed'), description: getErrorMessage(validateError) }) |
| 483 | return false |
| 484 | } |
| 485 | finally { |
| 486 | if (action === 'validate') |
| 487 | setLoadingAction(null) |
| 488 | } |
| 489 | }, [getEditableConfig, insertedRelayPath, serviceTarget, t]) |
| 490 | |
| 491 | const saveConfig = useCallback(async (action: LoadingAction = 'save') => { |
| 492 | const editableConfig = getEditableConfig() |
| 493 | if (!editableConfig) |
| 494 | return false |
| 495 | const submittableConfig = stripInsertedRelayPlaceholder(editableConfig, insertedRelayPath, serviceTarget) |
| 496 | |
| 497 | setLoadingAction(action) |
| 498 | setError(null) |
| 499 | setSuccessMessage('') |
| 500 | |
| 501 | try { |
| 502 | const valid = await validateConfig(action, editableConfig) |
| 503 | if (!valid) |
| 504 | return false |
| 505 | |
| 506 | const response = await saveConfigEditorConfigApi({ config: submittableConfig }, serviceTarget, true) |
| 507 | if (!response || response.code !== 0) { |
| 508 | throw new Error(getResponseMessage(response) || t('errors.saveFailed')) |
| 509 | } |
| 510 | |
| 511 | const normalizedConfig = ensureRelayConfig(submittableConfig, serviceTarget) |
| 512 | setConfig(normalizedConfig.config) |
| 513 | setOriginalConfig(normalizedConfig.config) |
| 514 | setJsonText(formatJsonConfig(normalizedConfig.config)) |
| 515 | setInsertedRelayPath(normalizedConfig.insertedRelayPath) |
| 516 | if (action === 'save') { |
| 517 | setSuccessMessage(t('messages.saveSuccess')) |
| 518 | toast.success(t('messages.saveSuccess')) |
| 519 | } |
| 520 | return true |
| 521 | } |
| 522 | catch (saveError) { |
| 523 | setError({ title: t('errors.saveFailed'), description: getErrorMessage(saveError) }) |
| 524 | return false |
| 525 | } |
| 526 | finally { |
| 527 | if (action === 'save') |
| 528 | setLoadingAction(null) |
| 529 | } |
| 530 | }, [getEditableConfig, insertedRelayPath, serviceTarget, t, validateConfig]) |
| 531 | |
| 532 | const waitForHealth = useCallback(async () => { |
| 533 | setServiceStatus('restarting') |
| 534 | setHealthAttempts(0) |
| 535 | |
| 536 | for (let attempt = 1; attempt <= healthCheckMaxAttempts; attempt += 1) { |
| 537 | setHealthAttempts(attempt) |
| 538 | await new Promise(resolve => window.setTimeout(resolve, healthCheckIntervalMs)) |
| 539 | const healthy = await checkConfigEditorConfigReadyApi(serviceTarget) |
| 540 | if (healthy) { |
| 541 | setServiceStatus('running') |
| 542 | setSuccessMessage(t('messages.restartSuccess')) |
| 543 | toast.success(t('messages.restartSuccess')) |
| 544 | return true |
| 545 | } |
| 546 | } |
| 547 | |
| 548 | setServiceStatus('failed') |
| 549 | setError({ title: t('errors.healthTimeout'), description: t('errors.healthTimeoutDescription') }) |
| 550 | return false |
| 551 | }, [serviceTarget, t]) |
| 552 | |
| 553 | const restartService = useCallback(async (action: LoadingAction = 'restart') => { |
| 554 | setLoadingAction(action) |
| 555 | setError(null) |
| 556 | setSuccessMessage('') |
| 557 | |
| 558 | try { |
| 559 | const response = await restartConfigEditorServiceApi(serviceTarget, true) |
| 560 | if (!response || response.code !== 0) { |
| 561 | throw new Error(getResponseMessage(response) || t('errors.restartFailed')) |
| 562 | } |
| 563 | |
| 564 | await waitForHealth() |
| 565 | } |
| 566 | catch (restartError) { |
| 567 | setServiceStatus('failed') |
| 568 | setError({ title: t('errors.restartFailed'), description: getErrorMessage(restartError) }) |
| 569 | } |
| 570 | finally { |
| 571 | setLoadingAction(null) |
| 572 | } |
| 573 | }, [serviceTarget, t, waitForHealth]) |
| 574 | |
| 575 | const handleSaveAndRestart = useCallback(async () => { |
| 576 | const saved = await saveConfig('saveRestart') |
| 577 | if (!saved) { |
| 578 | setLoadingAction(null) |
| 579 | return |
| 580 | } |
| 581 | await restartService('saveRestart') |
| 582 | }, [restartService, saveConfig]) |
| 583 | |
| 584 | const status: ConfigEditorStatus = { |
| 585 | service: serviceStatus, |
| 586 | format, |
| 587 | dirty, |
| 588 | } |
| 589 | const isReloading = loadingAction === 'load' |
| 590 | const isValidating = loadingAction === 'validate' |
| 591 | const isSaving = loadingAction === 'save' |
| 592 | const isRestarting = loadingAction === 'saveRestart' || loadingAction === 'restart' |
| 593 | |
| 594 | return ( |
| 595 | <Dialog open={open} onOpenChange={isOpen => !isOpen && onClose()}> |
| 596 | <DialogContent |
| 597 | className="h-[88vh] max-h-[760px] w-[calc(100vw-24px)] max-w-[1120px] gap-0 overflow-hidden p-0 sm:p-0" |
| 598 | aria-describedby={undefined} |
| 599 | > |
| 600 | <DialogTitle className="sr-only">{t('title')}</DialogTitle> |
| 601 | <DialogDescription className="sr-only">{t('description')}</DialogDescription> |
| 602 | |
| 603 | <div className="flex h-full min-h-0 flex-col"> |
| 604 | <header className="flex shrink-0 flex-col gap-3 border-b border-border px-5 py-4 md:flex-row md:items-center md:justify-between"> |
| 605 | <div className="flex min-w-0 items-start gap-3"> |
| 606 | <div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-brand-cyan/10 text-brand-cyan"> |
| 607 | <FileSliders className="h-5 w-5" /> |
| 608 | </div> |
| 609 | <div className="min-w-0"> |
| 610 | <h2 className="text-lg font-semibold text-foreground">{t('title')}</h2> |
| 611 | <p className="mt-1 text-sm text-muted-foreground">{t('description')}</p> |
| 612 | </div> |
| 613 | </div> |
| 614 | <div className="flex flex-wrap items-center gap-2 pr-8"> |
| 615 | <StatusBadge status={status} /> |
| 616 | {dirty && <Badge variant="outline" className="border-warning/30 bg-warning/10 text-warning">{t('status.dirty')}</Badge>} |
| 617 | </div> |
| 618 | </header> |
| 619 | |
| 620 | {loadingAction === 'load' && !config |
| 621 | ? <LoadingSkeleton /> |
| 622 | : ( |
| 623 | <div className="flex min-h-0 flex-1 flex-col"> |
| 624 | <div className="flex shrink-0 flex-col gap-2 border-b border-border px-4 py-2 md:flex-row md:items-center md:justify-between"> |
| 625 | <Tabs value={serviceTarget} onValueChange={handleServiceTargetChange}> |
| 626 | <TabsList className="h-9"> |
| 627 | <TabsTrigger value={ConfigEditorServiceTarget.Server} disabled={serverServiceDisabled} className="gap-1.5 text-xs"> |
| 628 | <Server className="h-3.5 w-3.5" /> |
| 629 | {t('services.server')} |
| 630 | </TabsTrigger> |
| 631 | <TabsTrigger value={ConfigEditorServiceTarget.Ai} disabled={aiServiceDisabled} className="gap-1.5 text-xs"> |
| 632 | <Bot className="h-3.5 w-3.5" /> |
| 633 | {t('services.ai')} |
| 634 | </TabsTrigger> |
| 635 | </TabsList> |
| 636 | </Tabs> |
| 637 | <div className="flex items-center gap-2 self-end md:self-auto"> |
| 638 | {editMode === 'json' && ( |
| 639 | <span className="hidden text-xs text-muted-foreground sm:inline">{t('messages.jsonHint')}</span> |
| 640 | )} |
| 641 | <Tabs value={editMode} onValueChange={handleEditModeChange}> |
| 642 | <TabsList className="h-8 rounded-lg border border-border bg-background p-0.5 shadow-sm" aria-label={t('tabs.modeSwitch')}> |
| 643 | <TabsTrigger |
| 644 | value="visual" |
| 645 | className="h-7 w-8 rounded-md px-0 data-[state=active]:bg-muted data-[state=active]:shadow-none" |
| 646 | title={t('tabs.visual')} |
| 647 | aria-label={t('tabs.visual')} |
| 648 | > |
| 649 | <SlidersHorizontal className="h-3.5 w-3.5" /> |
| 650 | <span className="sr-only">{t('tabs.visual')}</span> |
| 651 | </TabsTrigger> |
| 652 | <TabsTrigger |
| 653 | value="json" |
| 654 | className="h-7 w-8 rounded-md px-0 data-[state=active]:bg-muted data-[state=active]:shadow-none" |
| 655 | title={t('tabs.json')} |
| 656 | aria-label={t('tabs.json')} |
| 657 | > |
| 658 | <Braces className="h-3.5 w-3.5" /> |
| 659 | <span className="sr-only">{t('tabs.json')}</span> |
| 660 | </TabsTrigger> |
| 661 | </TabsList> |
| 662 | </Tabs> |
| 663 | </div> |
| 664 | </div> |
| 665 | |
| 666 | {editMode === 'visual' |
| 667 | ? ( |
| 668 | <div className="flex min-h-0 flex-1"> |
| 669 | <aside className="hidden w-60 shrink-0 border-r border-border bg-card md:block"> |
| 670 | <ConfigSectionNav |
| 671 | sections={sections} |
| 672 | activeSectionId={activeSectionId} |
| 673 | disabled={disabled} |
| 674 | onSectionClick={scrollToSection} |
| 675 | /> |
| 676 | </aside> |
| 677 | |
| 678 | <main className="min-w-0 flex-1"> |
| 679 | {config |
| 680 | ? ( |
| 681 | <ConfigFormPanel |
| 682 | sections={sections} |
| 683 | config={config} |
| 684 | originalConfig={originalConfig} |
| 685 | disabled={disabled} |
| 686 | scrollContainerRef={scrollContainerRef} |
| 687 | focusRequest={visualFocusRequest} |
| 688 | highlightedPathKey={highlightedVisualPathKey} |
| 689 | initialScrollTop={visualScrollTopRef.current} |
| 690 | onFocusRequestHandled={handleVisualFocusRequestHandled} |
| 691 | onValueChange={handleValueChange} |
| 692 | onNavigateToJson={handleNavigateToJson} |
| 693 | onScrollTopChange={handleVisualScrollTopChange} |
| 694 | onSectionClick={scrollToSection} |
| 695 | /> |
| 696 | ) |
| 697 | : ( |
| 698 | <div className="flex h-full items-center justify-center p-6"> |
| 699 | <Alert variant="destructive" className="max-w-xl"> |
| 700 | <AlertCircle className="h-4 w-4" /> |
| 701 | <div> |
| 702 | <AlertTitle>{error?.title ?? t('errors.loadFailed')}</AlertTitle> |
| 703 | {error?.description && <AlertDescription>{error.description}</AlertDescription>} |
| 704 | </div> |
| 705 | </Alert> |
| 706 | </div> |
| 707 | )} |
| 708 | </main> |
| 709 | </div> |
| 710 | ) |
| 711 | : ( |
| 712 | <div className="min-h-0 flex-1 bg-background p-4"> |
| 713 | <ConfigJsonPanel |
| 714 | jsonText={jsonText} |
| 715 | disabled={disabled} |
| 716 | hasConfig={!!config} |
| 717 | focusRequest={jsonFocusRequest} |
| 718 | highlightedPathKey={highlightedJsonPathKey} |
| 719 | initialScrollTop={jsonScrollTop} |
| 720 | onFocusRequestHandled={handleJsonFocusRequestHandled} |
| 721 | onJsonTextChange={handleJsonTextChange} |
| 722 | onScrollTopChange={setJsonScrollTop} |
| 723 | onNavigateToVisual={handleNavigateToVisual} |
| 724 | /> |
| 725 | </div> |
| 726 | )} |
| 727 | </div> |
| 728 | )} |
| 729 | |
| 730 | <footer className="flex shrink-0 flex-col gap-3 border-t border-border bg-background px-5 py-3 md:flex-row md:items-center md:justify-between"> |
| 731 | <div className="min-w-0 flex-1"> |
| 732 | {error && config && ( |
| 733 | <div className="flex items-start gap-2 text-sm text-destructive"> |
| 734 | <AlertCircle className="mt-0.5 h-4 w-4 shrink-0" /> |
| 735 | <span className="line-clamp-2"> |
| 736 | {error.title} |
| 737 | {error.description ? `:${error.description}` : ''} |
| 738 | </span> |
| 739 | </div> |
| 740 | )} |
| 741 | {!error && successMessage && ( |
| 742 | <div className="flex items-center gap-2 text-sm text-success"> |
| 743 | <CheckCircle2 className="h-4 w-4" /> |
| 744 | <span>{successMessage}</span> |
| 745 | </div> |
| 746 | )} |
| 747 | {!error && !successMessage && serviceStatus === 'restarting' && ( |
| 748 | <div className="flex items-center gap-2 text-sm text-muted-foreground"> |
| 749 | <Loader2 className="h-4 w-4 animate-spin text-primary" /> |
| 750 | <span>{t('messages.healthChecking', { current: healthAttempts, total: healthCheckMaxAttempts })}</span> |
| 751 | </div> |
| 752 | )} |
| 753 | {!error && !successMessage && serviceStatus !== 'restarting' && ( |
| 754 | <div className="text-sm text-muted-foreground"> |
| 755 | {dirty ? t('messages.dirtyHint') : t('messages.cleanHint')} |
| 756 | </div> |
| 757 | )} |
| 758 | </div> |
| 759 | |
| 760 | <div className="flex flex-wrap justify-end gap-2"> |
| 761 | <Button type="button" variant="outline" disabled={disabled} onClick={loadConfig}> |
| 762 | {isReloading ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCw className="h-4 w-4" />} |
| 763 | {t('actions.reload')} |
| 764 | </Button> |
| 765 | <Button type="button" variant="outline" disabled={disabled || !config} onClick={() => validateConfig()}> |
| 766 | {isValidating ? <Loader2 className="h-4 w-4 animate-spin" /> : <ShieldCheck className="h-4 w-4" />} |
| 767 | {t('actions.validate')} |
| 768 | </Button> |
| 769 | <Button type="button" variant="outline" disabled={disabled || !config || !dirty} onClick={() => saveConfig()}> |
| 770 | {isSaving ? <Loader2 className="h-4 w-4 animate-spin" /> : <Save className="h-4 w-4" />} |
| 771 | {t('actions.save')} |
| 772 | </Button> |
| 773 | <Button type="button" disabled={disabled || !config} onClick={dirty ? handleSaveAndRestart : () => restartService()}> |
| 774 | {isRestarting ? <Loader2 className="h-4 w-4 animate-spin" /> : <RotateCcw className="h-4 w-4" />} |
| 775 | {dirty ? t('actions.saveAndRestart') : t('actions.restart')} |
| 776 | </Button> |
| 777 | </div> |
| 778 | </footer> |
| 779 | </div> |
| 780 | </DialogContent> |
| 781 | </Dialog> |
| 782 | ) |
| 783 | } |
| 784 | |
| 785 | export default ConfigManagerDialog |
| 786 |