import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useSearchParams } from "react-router-dom"; import { ListPager } from "../components/ListPager"; import { queryKeys } from "../constants/queryKeys"; import { useDebouncedValue } from "../hooks/useDebouncedValue"; import { useToast } from "../hooks/useToast"; import { useI18n } from "../i18n"; import { createConfigSyncCycle, fetchCliTargets, fetchConfigSyncCycleTasks, fetchConfigSyncCycles, fetchConfigSyncDashboard, formatErr, pauseConfigSyncCycle, resumeConfigSyncCycle, stopConfigSyncCycle, updateConfigSyncPolicy, } from "../services/api"; import type { CliTargetItem, ConfigSyncCycle, ConfigSyncTargetRef, ConfigSyncTask } from "../types"; import { downloadCsv, fetchAllPages } from "../utils/csvExport"; import { pageCount } from "../utils/display"; import { formatSystemTime } from "../utils/time"; const POLL_MS = 2500; const TARGET_PAGE_SIZE = 20; export function ConfigSyncPage() { const { t } = useI18n(); const { showOk, showError } = useToast(); const queryClient = useQueryClient(); const [searchParams, setSearchParams] = useSearchParams(); const [cyclePage, setCyclePage] = useState(1); const [cyclePageSize, setCyclePageSize] = useState(20); const [cycleStatus, setCycleStatus] = useState(""); const [cycleKeyword, setCycleKeyword] = useState(""); const [exportingCycles, setExportingCycles] = useState(false); const [expandedCycleId, setExpandedCycleId] = useState(""); const [taskPage, setTaskPage] = useState(1); const [taskPageSize, setTaskPageSize] = useState(20); const [taskStatus, setTaskStatus] = useState(""); const [taskKeyword, setTaskKeyword] = useState(""); const [exportingTasks, setExportingTasks] = useState(false); const debouncedCycleKeyword = useDebouncedValue(cycleKeyword, 300); const debouncedTaskKeyword = useDebouncedValue(taskKeyword, 300); const [enabled, setEnabled] = useState(false); const [intervalDays, setIntervalDays] = useState(3); const [concurrency, setConcurrency] = useState(5); const [scopeMode, setScopeMode] = useState<"all" | "selected">("all"); const [historyKeep, setHistoryKeep] = useState(3); const [cycleKeep, setCycleKeep] = useState(30); const [selectedMap, setSelectedMap] = useState>({}); const [policyHydrated, setPolicyHydrated] = useState(false); const [targetKeyword, setTargetKeyword] = useState(""); const [targetPage, setTargetPage] = useState(1); const dashQuery = useQuery({ queryKey: queryKeys.configSyncDashboard, queryFn: fetchConfigSyncDashboard, staleTime: 1000, refetchInterval: (q) => { const running = q.state.data?.running_cycle; return running && (running.status === "running" || running.status === "paused" || running.status === "pending") ? POLL_MS : false; }, }); useEffect(() => { if (!dashQuery.data || policyHydrated) return; const p = dashQuery.data.policy; setEnabled(Boolean(p.enabled)); setIntervalDays(Number(p.interval_days || 3)); setConcurrency(Number(p.concurrency || 5)); setScopeMode(p.scope_mode === "selected" ? "selected" : "all"); setHistoryKeep(Number(p.history_keep ?? 3)); setCycleKeep(Math.max(0, Math.min(200, Number(p.cycle_keep ?? 30)))); const map: Record = {}; for (const ref of p.selected_targets || []) { map[`${ref.source}:${ref.id}`] = { source: ref.source, id: ref.id }; } setSelectedMap(map); setPolicyHydrated(true); }, [dashQuery.data, policyHydrated]); useEffect(() => { const id = String(searchParams.get("cycle_id") || "").trim(); if (!id) return; setExpandedCycleId(id); setTaskPage(1); setTaskPageSize(20); setTaskKeyword(""); setTaskStatus(""); setSearchParams( (prev) => { const next = new URLSearchParams(prev); if (next.get("cycle_id") === id) next.delete("cycle_id"); return next; }, { replace: true }, ); }, [searchParams, setSearchParams]); const cyclesQuery = useQuery({ queryKey: queryKeys.configSyncCycles(cyclePage, cyclePageSize, cycleStatus, debouncedCycleKeyword), queryFn: () => fetchConfigSyncCycles({ page: cyclePage, pageSize: cyclePageSize, status: cycleStatus, keyword: debouncedCycleKeyword, }), staleTime: 1000, refetchInterval: () => (dashQuery.data?.running_cycle ? POLL_MS : false), }); const tasksQuery = useQuery({ queryKey: queryKeys.configSyncCycleTasks( expandedCycleId, taskPage, taskPageSize, taskStatus, debouncedTaskKeyword, ), queryFn: () => fetchConfigSyncCycleTasks({ cycleId: expandedCycleId, page: taskPage, pageSize: taskPageSize, status: taskStatus, keyword: debouncedTaskKeyword, }), enabled: Boolean(expandedCycleId), staleTime: 800, refetchInterval: () => (dashQuery.data?.running_cycle?.id === expandedCycleId ? POLL_MS : false), }); const targetsQuery = useQuery({ queryKey: queryKeys.cliTargets(targetKeyword, targetPage, TARGET_PAGE_SIZE), queryFn: () => fetchCliTargets({ source: "all", keyword: targetKeyword, page: targetPage, pageSize: TARGET_PAGE_SIZE }), enabled: scopeMode === "selected", staleTime: 5000, }); const refresh = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.configSyncDashboard }), queryClient.invalidateQueries({ queryKey: queryKeys.configSyncCyclesAll }), queryClient.invalidateQueries({ queryKey: queryKeys.configSyncCycleTasksAll }), ]); }; const savePolicyMut = useMutation({ mutationFn: () => updateConfigSyncPolicy({ enabled, interval_days: intervalDays, concurrency, scope_mode: scopeMode, history_keep: historyKeep, cycle_keep: cycleKeep, selected_targets: Object.values(selectedMap), }), onSuccess: async (saved) => { // Apply server response immediately — do NOT flip policyHydrated false then // rehydrate from a possibly-stale dashboard cache (checkbox "pops back" bug). setEnabled(Boolean(saved.enabled)); setIntervalDays(Number(saved.interval_days || 3)); setConcurrency(Number(saved.concurrency || 5)); setScopeMode(saved.scope_mode === "selected" ? "selected" : "all"); setHistoryKeep(Number(saved.history_keep ?? 3)); setCycleKeep(Math.max(0, Math.min(200, Number(saved.cycle_keep ?? 30)))); const map: Record = {}; for (const ref of saved.selected_targets || []) { map[`${ref.source}:${ref.id}`] = { source: ref.source, id: ref.id }; } setSelectedMap(map); setPolicyHydrated(true); queryClient.setQueryData(queryKeys.configSyncDashboard, (prev: unknown) => { if (!prev || typeof prev !== "object") return prev; return { ...(prev as object), policy: saved }; }); showOk(t("configSync.policySaved")); await refresh(); }, onError: (err) => showError(formatErr(err)), }); const startMut = useMutation({ mutationFn: (mode: "full" | "retry_failed") => createConfigSyncCycle({ mode }), onSuccess: async () => { showOk(t("configSync.started")); await refresh(); }, onError: (err) => showError(formatErr(err)), }); const pauseMut = useMutation({ mutationFn: (id: string) => pauseConfigSyncCycle(id), onSuccess: async () => { showOk(t("configSync.paused")); await refresh(); }, onError: (err) => showError(formatErr(err)), }); const resumeMut = useMutation({ mutationFn: (id: string) => resumeConfigSyncCycle(id), onSuccess: async () => { showOk(t("configSync.resumed")); await refresh(); }, onError: (err) => showError(formatErr(err)), }); const stopMut = useMutation({ mutationFn: (id: string) => stopConfigSyncCycle(id), onSuccess: async () => { showOk(t("configSync.stopped")); await refresh(); }, onError: (err) => showError(formatErr(err)), }); const dash = dashQuery.data; const running = dash?.running_cycle; const last = dash?.last_cycle; const cycles = cyclesQuery.data?.items ?? []; const cycleTotal = Number(cyclesQuery.data?.total || 0); const cyclePages = pageCount(cycleTotal, cyclePageSize); const taskTotal = Number(tasksQuery.data?.total || 0); const taskPages = pageCount(taskTotal, taskPageSize); const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]); const hasCycleFilters = Boolean(cycleKeyword.trim() || cycleStatus); const openCycleTasks = (cycleId: string) => { setExpandedCycleId(cycleId); setTaskPage(1); setTaskKeyword(""); setTaskStatus(""); }; const exportCyclesCsv = async () => { setExportingCycles(true); try { const rows = await fetchAllPages({ pageSize: 100, maxRows: 2000, fetchPage: (p, ps) => fetchConfigSyncCycles({ page: p, pageSize: ps, status: cycleStatus, keyword: debouncedCycleKeyword, }), }); downloadCsv( `${t("configSync.exportCyclesName")}-${new Date().toISOString().slice(0, 10)}.csv`, rows, [ { key: "id", header: "ID" }, { key: "trigger_mode", header: t("configSync.col.trigger") }, { key: "status", header: t("configSync.col.status") }, { key: "progress", header: t("configSync.col.progress"), value: (r) => `${r.success_count}/${r.planned_count} fail ${r.fail_count}`, }, { key: "started_at", header: t("configSync.col.started"), value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""), }, { key: "ended_at", header: t("configSync.col.ended"), value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""), }, { key: "error_message", header: t("configSync.col.message") }, ], ); if (rows.length < cycleTotal) { showOk(t("common.exportTruncated", { count: String(rows.length), total: String(cycleTotal) })); } else { showOk(t("common.exportOk", { count: String(rows.length) })); } } catch (err) { showError(t("common.exportFailed") + ": " + formatErr(err)); } finally { setExportingCycles(false); } }; const exportTasksCsv = async () => { if (!expandedCycleId) return; setExportingTasks(true); try { const rows = await fetchAllPages({ pageSize: 200, maxRows: 2000, fetchPage: (p, ps) => fetchConfigSyncCycleTasks({ cycleId: expandedCycleId, page: p, pageSize: ps, status: taskStatus, keyword: debouncedTaskKeyword, }), }); downloadCsv( `${t("configSync.exportTasksName")}-${expandedCycleId.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}.csv`, rows, [ { key: "ne_name", header: t("configSync.col.name"), value: (r) => r.ne_name || r.target_id }, { key: "ne_ip", header: "IP" }, { key: "vendor", header: t("configSync.col.vendor") }, { key: "source", header: t("configSync.col.source") }, { key: "status", header: t("configSync.col.status") }, { key: "message", header: t("configSync.col.message") }, { key: "target_id", header: "ID" }, ], ); if (rows.length < taskTotal) { showOk(t("common.exportTruncated", { count: String(rows.length), total: String(taskTotal) })); } else { showOk(t("common.exportOk", { count: String(rows.length) })); } } catch (err) { showError(t("common.exportFailed") + ": " + formatErr(err)); } finally { setExportingTasks(false); } }; const toggleTarget = (row: CliTargetItem) => { const source = row.source === "ume" ? "ume" : "managed"; const key = `${source}:${row.id}`; setSelectedMap((prev) => { const next = { ...prev }; if (next[key]) delete next[key]; else next[key] = { source, id: row.id }; return next; }); }; return (

{t("configSync.title")}

{running?.status === "running" || running?.status === "pending" ? ( ) : null} {running?.status === "paused" ? ( ) : null} {running && (running.status === "running" || running.status === "paused" || running.status === "pending") ? ( ) : null}
{t("configSync.kpi.snapshots")}
{dash?.snapshot_count ?? "—"}
{t("configSync.kpi.running")}
{running ? `${running.status} · ${running.success_count}/${running.planned_count}` : t("configSync.kpi.idle")}
{t("configSync.kpi.last")}
{last ? `${last.status} · ok ${last.success_count} / fail ${last.fail_count}` : t("common.empty")}
{t("configSync.kpi.nextDue")}
{dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}

{t("configSync.policyTitle")}

{scopeMode === "selected" ? (

{t("configSync.selectedCount", { count: String(selectedCount) })}

{ setTargetKeyword(e.target.value); setTargetPage(1); }} />
{(targetsQuery.data?.items ?? []).map((row) => { const source = row.source === "ume" ? "ume" : "managed"; const key = `${source}:${row.id}`; return ( ); })}
{t("configSync.col.source")} {t("configSync.col.name")} IP {t("configSync.col.vendor")}
toggleTarget(row)} /> {source} {row.name} {row.ip_address} {row.vendor || "-"}
{t("common.pagerMeta", { total: String(targetsQuery.data?.total ?? 0), page: String(targetPage), pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)), })}
) : null}

{t("configSync.cyclesTitle")}

{ setCycleKeyword(e.target.value); setCyclePage(1); }} />
{cyclesQuery.isLoading ?

{t("common.refreshing")}

: null} {cyclesQuery.isError ?

{formatErr(cyclesQuery.error)}

: null}
{cycles.map((c) => ( ))} {!cycles.length && !cyclesQuery.isLoading ? ( ) : null}
ID {t("configSync.col.trigger")} {t("configSync.col.status")} {t("configSync.col.progress")} {t("configSync.col.started")} {t("configSync.col.ended")} {t("configSync.col.actions")}
{c.id.slice(0, 8)} {c.trigger_mode} {c.status} {c.success_count}/{c.planned_count} · fail {c.fail_count} {c.started_at ? formatSystemTime(c.started_at) : "-"} {c.ended_at ? formatSystemTime(c.ended_at) : "-"}
{c.status === "running" || c.status === "pending" ? ( ) : null} {c.status === "paused" ? ( ) : null} {c.status === "running" || c.status === "paused" || c.status === "pending" ? ( ) : null}
{t("configSync.cyclesEmpty")}
{ setCyclePageSize(size); setCyclePage(1); }} disabled={cyclesQuery.isLoading} />
{expandedCycleId ? (
setExpandedCycleId("")} >
e.stopPropagation()} >

{t("configSync.tasksTitle")}

{expandedCycleId.slice(0, 8)} {(() => { const c = cycles.find((x) => x.id === expandedCycleId); return c ? ` · ${c.trigger_mode} · ${c.status} · ${c.success_count}/${c.planned_count}` : ""; })()}

{ setTaskKeyword(e.target.value); setTaskPage(1); }} />
{tasksQuery.isLoading ?

{t("common.refreshing")}

: null} {tasksQuery.isError ? (

{formatErr(tasksQuery.error)}

) : null}
{(tasksQuery.data?.items ?? []).map((task) => ( ))} {!tasksQuery.isLoading && !(tasksQuery.data?.items ?? []).length ? ( ) : null}
{t("configSync.col.name")} IP {t("configSync.col.vendor")} {t("configSync.col.source")} {t("configSync.col.status")} {t("configSync.col.message")}
{task.ne_name || task.target_id} {task.ne_ip} {task.vendor || "-"} {task.source} {task.status} {task.message || "-"}
{t("common.empty")}
{ setTaskPageSize(size); setTaskPage(1); }} disabled={tasksQuery.isLoading} />
) : null}
); }