import { useEffect, useMemo, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { fetchCliTargets, fetchFabricEdges, fetchLldpCollectDashboard, fetchLldpCollectJob, fetchLldpCollectJobs, pauseLldpCollectJob, resumeLldpCollectJob, startLldpCollect, stopLldpCollectJob, updateLldpCollectPolicy, } from "../../services/api"; import { queryKeys } from "../../constants/queryKeys"; import { useI18n } from "../../i18n"; import { useToast } from "../../hooks/useToast"; import type { CliTargetItem, ConfigSyncTargetRef, TopologyDiscoverJobItem } from "../../types"; import { pageCount } from "../../utils/display"; import { formatSystemTime } from "../../utils/time"; const POLL_MS = 2500; const TARGET_PAGE_SIZE = 20; const SEP = " · "; export function LldpLinksPage() { const { t } = useI18n(); const { showOk, showError } = useToast(); const queryClient = useQueryClient(); const [jobPage, setJobPage] = useState(1); const [expandedJobId, setExpandedJobId] = useState(""); const [itemPage, setItemPage] = useState(1); const [itemDetail, setItemDetail] = useState(null); const ITEM_PAGE_SIZE = 20; const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all"); const [edgeKeyword, setEdgeKeyword] = useState(""); const [edgePage, setEdgePage] = useState(1); const EDGE_PAGE_SIZE = 20; const [enabled, setEnabled] = useState(false); const [intervalValue, setIntervalValue] = useState(1); const [intervalUnit, setIntervalUnit] = useState<"days" | "hours">("days"); const [concurrency, setConcurrency] = useState(4); const [historyKeep, setHistoryKeep] = useState(30); const [scopeMode, setScopeMode] = useState<"all" | "selected">("all"); const [autoAdd, setAutoAdd] = useState(true); const [selectedMap, setSelectedMap] = useState>({}); const [policyHydrated, setPolicyHydrated] = useState(false); const [targetKeyword, setTargetKeyword] = useState(""); const [targetPage, setTargetPage] = useState(1); const dashQuery = useQuery({ queryKey: queryKeys.lldpCollectDashboard, queryFn: fetchLldpCollectDashboard, staleTime: 1000, refetchInterval: (q) => { const running = q.state.data?.running_job; return running && (running.status === "running" || running.status === "pending" || running.status === "paused") ? POLL_MS : false; }, }); useEffect(() => { if (!dashQuery.data || policyHydrated) return; const p = dashQuery.data.policy; setEnabled(Boolean(p.enabled)); const hours = Math.max(1, Number(p.interval_hours || (Number(p.interval_days || 1) * 24))); if (hours % 24 === 0) { setIntervalUnit("days"); setIntervalValue(Math.max(1, Math.min(365, hours / 24))); } else { setIntervalUnit("hours"); setIntervalValue(Math.max(1, Math.min(8760, hours))); } setConcurrency(Number(p.concurrency || 4)); setHistoryKeep(Math.max(0, Math.min(200, Number(p.history_keep ?? 30)))); setScopeMode(p.scope_mode === "selected" ? "selected" : "all"); setAutoAdd(Boolean(p.auto_add_unmatched)); const map: Record = {}; for (const ref of p.selected_targets || []) { const source = ref.source === "ume" ? "ume" : "managed"; map[`${source}:${ref.id}`] = { source, id: ref.id }; } setSelectedMap(map); setPolicyHydrated(true); }, [dashQuery.data, policyHydrated]); const jobsQuery = useQuery({ queryKey: queryKeys.lldpCollectJobs(jobPage), queryFn: () => fetchLldpCollectJobs({ page: jobPage, pageSize: 10 }), staleTime: 1000, refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false), }); const jobDetailQuery = useQuery({ queryKey: queryKeys.lldpCollectJob(expandedJobId, itemPage), queryFn: () => fetchLldpCollectJob(expandedJobId, { page: itemPage, pageSize: ITEM_PAGE_SIZE }), enabled: Boolean(expandedJobId), staleTime: 800, refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? 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 edgesQuery = useQuery({ queryKey: queryKeys.fabricEdges(edgeStatus, edgeKeyword, edgePage), queryFn: () => fetchFabricEdges({ status: edgeStatus === "all" ? "" : edgeStatus, keyword: edgeKeyword, page: edgePage, pageSize: EDGE_PAGE_SIZE, }), staleTime: 2000, refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false), }); const refresh = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard }), queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectJobsAll }), queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectJobAll }), queryClient.invalidateQueries({ queryKey: queryKeys.fabricEdgesAll }), ]); }; const savePolicyMut = useMutation({ mutationFn: () => { const hours = intervalUnit === "days" ? Math.max(1, Math.min(365, intervalValue)) * 24 : Math.max(1, Math.min(8760, intervalValue)); return updateLldpCollectPolicy({ enabled, interval_hours: hours, concurrency, history_keep: historyKeep, scope_mode: scopeMode, auto_add_unmatched: autoAdd, 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)); const hours = Math.max(1, Number(saved.interval_hours || (Number(saved.interval_days || 1) * 24))); if (hours % 24 === 0) { setIntervalUnit("days"); setIntervalValue(Math.max(1, Math.min(365, hours / 24))); } else { setIntervalUnit("hours"); setIntervalValue(Math.max(1, Math.min(8760, hours))); } setConcurrency(Number(saved.concurrency || 4)); setHistoryKeep(Math.max(0, Math.min(200, Number(saved.history_keep ?? 30)))); setScopeMode(saved.scope_mode === "selected" ? "selected" : "all"); setAutoAdd(Boolean(saved.auto_add_unmatched)); const map: Record = {}; for (const ref of saved.selected_targets || []) { const source = ref.source === "ume" ? "ume" : "managed"; map[`${source}:${ref.id}`] = { source, id: ref.id }; } setSelectedMap(map); setPolicyHydrated(true); queryClient.setQueryData(queryKeys.lldpCollectDashboard, (prev: unknown) => { if (!prev || typeof prev !== "object") return prev; return { ...(prev as object), policy: saved }; }); showOk(t("lldpLinks.policySaved")); await refresh(); }, onError: (err) => showError(String(err)), }); const startMut = useMutation({ mutationFn: () => startLldpCollect(), onSuccess: async () => { showOk(t("lldpLinks.started")); await refresh(); }, onError: (err) => showError(String(err)), }); const pauseMut = useMutation({ mutationFn: (id: string) => pauseLldpCollectJob(id), onSuccess: async () => { showOk(t("lldpLinks.paused")); await refresh(); }, onError: (err) => showError(String(err)), }); const resumeMut = useMutation({ mutationFn: (id: string) => resumeLldpCollectJob(id), onSuccess: async () => { showOk(t("lldpLinks.resumed")); await refresh(); }, onError: (err) => showError(String(err)), }); const stopMut = useMutation({ mutationFn: (id: string) => stopLldpCollectJob(id), onSuccess: async () => { showOk(t("lldpLinks.stopped")); await refresh(); }, onError: (err) => showError(String(err)), }); const dash = dashQuery.data; const running = dash?.running_job; const last = dash?.last_job; const jobs = jobsQuery.data?.items ?? []; const jobTotal = Number(jobsQuery.data?.total || 0); const jobPages = pageCount(jobTotal, 10); const selectedCount = useMemo(() => Object.keys(selectedMap).length, [selectedMap]); const detailItems = jobDetailQuery.data?.items ?? []; const itemTotal = Number(jobDetailQuery.data?.items_total ?? detailItems.length); const itemPages = pageCount(itemTotal, ITEM_PAGE_SIZE); const edgeItems = edgesQuery.data?.items ?? []; const edgeTotal = Number(edgesQuery.data?.total || 0); const edgePages = pageCount(edgeTotal, EDGE_PAGE_SIZE); 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("lldpLinks.title")}

{running?.status === "running" || running?.status === "pending" ? ( ) : null} {running?.status === "paused" ? ( ) : null} {running && (running.status === "running" || running.status === "paused" || running.status === "pending") ? ( ) : null}
{t("lldpLinks.kpi.nodes")}
{dash?.fabric_node_count ?? "—"}
{t("lldpLinks.kpi.edges")}
{dash?.fabric_edge_active ?? "—"}
{t("lldpLinks.kpi.missing")}
{dash?.fabric_edge_missing ?? dash?.fabric_edge_stale ?? "—"}
{t("lldpLinks.kpi.running")}
{running ? `${running.status} · ${running.done}/${running.total}` : t("lldpLinks.kpi.idle")}
{t("lldpLinks.kpi.last")}
{last ? `${last.status} · +${last.edges_added} / ~${last.edges_updated}` : t("common.empty")}
{t("lldpLinks.kpi.nextDue")}
{dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}

{t("lldpLinks.policyTitle")}

{scopeMode === "selected" ? (

{t("lldpLinks.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("lldpLinks.col.source")} {t("lldpLinks.col.name")} IP {t("lldpLinks.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("lldpLinks.edgesTitle")}

{ setEdgeKeyword(e.target.value); setEdgePage(1); }} />
{edgeItems.map((e) => { const attrs = (e.attrs || {}) as Record; const missCount = Number(attrs.miss_count || 0) || 0; const replaced = String(attrs.replaced_by_edge_id || "").trim(); const aLabel = e.a_name || e.a_ip || e.a_node_id.slice(0, 8); const bLabel = e.b_name || e.b_ip || e.b_node_id.slice(0, 8); const st = e.status === "stale" ? "missing" : e.status; return ( ); })} {!edgeItems.length ? ( ) : null}
{t("lldpLinks.col.status")} {t("lldpLinks.col.aSide")} {t("lldpLinks.col.aPort")} {t("lldpLinks.col.bSide")} {t("lldpLinks.col.bPort")} {t("lldpLinks.col.source")} {t("lldpLinks.missCount")} {t("lldpLinks.col.lastSeen")}
{st}
{aLabel}
{e.a_ip && e.a_name ?
{e.a_ip}
: null}
{e.a_port || "—"}
{bLabel}
{e.b_ip && e.b_name ?
{e.b_ip}
: null}
{e.b_port || "—"} {e.source} {missCount || (st === "missing" ? 1 : 0)} {replaced ? (
{t("lldpLinks.replacedBy")}
) : null}
{e.last_seen_at ? formatSystemTime(e.last_seen_at) : "—"}
{edgesQuery.isLoading ? "…" : t("common.empty")}
{t("common.pagerMeta", { total: String(edgeTotal), page: String(edgePage), pages: String(edgePages), })}

{t("lldpLinks.jobsTitle")}

{jobs.map((job) => { const open = expandedJobId === job.id; return ( ); })} {!jobs.length ? ( ) : null}
ID {t("lldpLinks.col.trigger")} {t("lldpLinks.col.scope")} {t("lldpLinks.col.status")} {t("lldpLinks.col.progress")} {t("lldpLinks.col.edges")} {t("lldpLinks.col.missingDelta")} {t("lldpLinks.col.started")} {t("lldpLinks.col.ended")}
{job.id.slice(0, 8)} {job.trigger_mode} {job.scope} {job.status} {job.done}/{job.total} +{job.edges_added} / ~{job.edges_updated} {job.edges_missing ?? job.edges_stale ?? 0} {job.started_at ? formatSystemTime(job.started_at) : "—"} {job.ended_at ? formatSystemTime(job.ended_at) : "—"}
{job.status === "running" || job.status === "pending" ? ( ) : null} {job.status === "paused" ? ( ) : null} {job.status === "running" || job.status === "paused" || job.status === "pending" ? ( ) : null}
{t("common.empty")}
{t("common.pagerMeta", { total: String(jobTotal), page: String(jobPage), pages: String(jobPages), })}
{expandedJobId ? (

{t("lldpLinks.jobDetailTitle")}

{jobDetailQuery.isLoading ?

…

: null} {jobDetailQuery.data?.error ? (

{jobDetailQuery.data.error}

) : null} {detailItems.map((it) => { const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0); return ( ); })} {!detailItems.length && !jobDetailQuery.isLoading ? ( ) : null}
{t("lldpLinks.col.name")} IP {t("lldpLinks.col.status")} {t("lldpLinks.col.neighbors")} {t("lldpLinks.col.edges")} {t("lldpLinks.col.error")}
{it.ne_name || it.ne_id || "—"} {it.ne_ip || "—"} {!it.ok ? "fail" : it.parser_stub || unmatchedCount > 0 ? "warn" : "ok"} {it.neighbors} +{it.edges_added} / ~{it.edges_updated} {unmatchedCount > 0 ? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}` : ""} {it.error || "—"}
{t("common.empty")}
{t("common.pagerMeta", { total: String(itemTotal), page: String(itemPage), pages: String(itemPages), })}
) : null} {itemDetail ? (
setItemDetail(null)}>
e.stopPropagation()} >

{itemDetail.ne_name || itemDetail.ne_ip || itemDetail.ne_id}

{[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}

{!itemDetail.ok ? (

{itemDetail.error === "vendor_or_device_type_required" ? t("topology.discoverVendorRequired") : itemDetail.error || t("topology.discoverNeFail")}

) : null} {itemDetail.parser_stub ? (

{t("topology.discoverParserStub").replace( "{{parser}}", itemDetail.parser_key || "unknown", )}

) : null}
{t("topology.discoverNeOk") .replace("{{neighbors}}", String(itemDetail.neighbors || 0)) .replace("{{added}}", String(itemDetail.edges_added || 0)) .replace("{{updated}}", String(itemDetail.edges_updated || 0))}

{t("topology.discoverUnmatchedTitle").replace( "{{count}}", String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0), )}

{(itemDetail.unmatched || []).length === 0 ? (

{t("topology.discoverUnmatchedEmpty")}

) : (
{(itemDetail.unmatched || []).map((u, idx) => ( ))}
{t("topology.discoverColRemote")} {t("topology.discoverColLocalPort")} {t("topology.discoverColRemotePort")}
{(u.remote_name || u.remote_ip || "?").trim()} {u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""} {u.local_port || "?"} {u.remote_port || "?"}
)} {itemDetail.raw_preview ? ( <>

{t("topology.discoverRawPreview")}

{itemDetail.raw_preview}
) : null}
) : null}
); }