Improve topology UX, traffic discover logs, and CLI wrap parsing.

Paginate LLDP job items, surface port-traffic raw logs with a 300s default, harden topology discover/edit/zoom/fullscreen, and join mid-token CLI wraps before TextFSM.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-03 19:24:43 +08:00
parent 66f26b738d
commit c13ab48ba7
22 changed files with 693 additions and 75 deletions

View file

@ -27,7 +27,9 @@ export function LldpLinksPage() {
const [jobPage, setJobPage] = useState(1);
const [expandedJobId, setExpandedJobId] = useState("");
const [itemPage, setItemPage] = useState(1);
const [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
const ITEM_PAGE_SIZE = 20;
const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
const [edgeKeyword, setEdgeKeyword] = useState("");
@ -90,8 +92,8 @@ export function LldpLinksPage() {
});
const jobDetailQuery = useQuery({
queryKey: queryKeys.lldpCollectJob(expandedJobId),
queryFn: () => fetchLldpCollectJob(expandedJobId),
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),
@ -193,6 +195,8 @@ export function LldpLinksPage() {
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);
@ -546,6 +550,7 @@ export function LldpLinksPage() {
onClick={() => {
setItemDetail(null);
setExpandedJobId(open ? "" : job.id);
setItemPage(1);
}}
>
{open ? "−" : "+"}
@ -653,6 +658,25 @@ export function LldpLinksPage() {
) : null}
</tbody>
</table>
<div className="pager">
<button type="button" disabled={itemPage <= 1} onClick={() => setItemPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<span className="muted">
{t("common.pagerMeta", {
total: String(itemTotal),
page: String(itemPage),
pages: String(itemPages),
})}
</span>
<button
type="button"
disabled={itemPage >= itemPages}
onClick={() => setItemPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
</div>
) : null}

View file

@ -126,7 +126,7 @@ export function PortTrafficPage() {
// Wizard / edit
const [editDeviceId, setEditDeviceId] = useState("");
const [editDeviceSnap, setEditDeviceSnap] = useState<PortTrafficDevice | null>(null);
const [intervalSec, setIntervalSec] = useState(60);
const [intervalSec, setIntervalSec] = useState(300);
const [retentionDays, setRetentionDays] = useState(7);
const [concurrency, setConcurrency] = useState(1);
const [note, setNote] = useState("");
@ -137,6 +137,12 @@ export function PortTrafficPage() {
const [ports, setPorts] = useState<PortTrafficDiscoverPort[]>([]);
const [portsLoading, setPortsLoading] = useState(false);
const [portsError, setPortsError] = useState("");
const [discoverMeta, setDiscoverMeta] = useState<{
ok: boolean;
command: string;
raw_preview: string;
error: string;
} | null>(null);
const [pickedIfnames, setPickedIfnames] = useState<Record<string, PortTrafficIfaceIn>>({});
const [deepLinkHint, setDeepLinkHint] = useState("");
const [deepLinkSource, setDeepLinkSource] = useState<"managed" | "ume" | "">("");
@ -366,7 +372,7 @@ export function PortTrafficPage() {
const resetWizard = () => {
setWizardStep(1);
setIntervalSec(60);
setIntervalSec(300);
setRetentionDays(7);
setConcurrency(1);
setNote("");
@ -374,6 +380,7 @@ export function PortTrafficPage() {
setSelectedNe(null);
setPorts([]);
setPortsError("");
setDiscoverMeta(null);
setPickedIfnames({});
setNeKeyword("");
setNePage(1);
@ -404,6 +411,7 @@ export function PortTrafficPage() {
setPickedIfnames({});
setPorts([]);
setPortsError("");
setDiscoverMeta(null);
setView("edit");
};
@ -434,11 +442,23 @@ export function PortTrafficPage() {
if (!id) return;
setPortsLoading(true);
setPortsError("");
setDiscoverMeta(null);
try {
const res = await discoverPortTrafficPorts({ source, id });
setPorts(res.ports);
setPorts(res.ports || []);
setDiscoverMeta({
ok: res.ok !== false,
command: res.command || "",
raw_preview: res.raw_preview || "",
error: res.error || "",
});
if (res.ok === false) {
const msg = res.error || t("portTraffic.discoverFail");
setPortsError(msg);
showError(msg);
}
if (deepLinkIfname) {
const hit = res.ports.find((p) => p.ifname === deepLinkIfname);
const hit = (res.ports || []).find((p) => p.ifname === deepLinkIfname);
if (hit) {
setPickedIfnames((prev) => ({
...prev,
@ -455,6 +475,12 @@ export function PortTrafficPage() {
const msg = e instanceof Error ? e.message : "discover_failed";
setPorts([]);
setPortsError(msg);
setDiscoverMeta({
ok: false,
command: "",
raw_preview: "",
error: msg,
});
showError(msg);
} finally {
setPortsLoading(false);
@ -841,6 +867,29 @@ export function PortTrafficPage() {
</button>
{portsError ? <span className="muted">{portsError}</span> : null}
</div>
{discoverMeta ? (
<details className="panel" open style={{ marginBottom: 12 }}>
<summary>
{t("portTraffic.discoverDetail")}
{" · "}
{discoverMeta.ok ? t("portTraffic.discoverOk") : t("portTraffic.discoverFail")}
</summary>
{discoverMeta.command ? (
<p className="muted">
{t("portTraffic.discoverCommand")}: <code>{discoverMeta.command}</code>
</p>
) : null}
{discoverMeta.error ? (
<p className="muted" style={{ color: "var(--danger, #b00)" }}>
{discoverMeta.error}
</p>
) : null}
<p className="muted">{t("portTraffic.discoverRaw")}</p>
<pre className="topo-discover-modal__raw" style={{ maxHeight: 240, overflow: "auto" }}>
{discoverMeta.raw_preview || "—"}
</pre>
</details>
) : null}
{ports.length ? (
<table className="data-table">
<thead>
@ -894,7 +943,7 @@ export function PortTrafficPage() {
min={15}
max={3600}
value={intervalSec}
onChange={(e) => setIntervalSec(Number(e.target.value) || 60)}
onChange={(e) => setIntervalSec(Number(e.target.value) || 300)}
/>
</label>
<label className="config-sync-policy-field">
@ -1005,7 +1054,7 @@ export function PortTrafficPage() {
min={15}
max={3600}
value={intervalSec}
onChange={(e) => setIntervalSec(Number(e.target.value) || 60)}
onChange={(e) => setIntervalSec(Number(e.target.value) || 300)}
/>
</label>
<label className="config-sync-policy-field">
@ -1040,6 +1089,29 @@ export function PortTrafficPage() {
<span className="muted">{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</span>
</div>
{portsError ? <p className="muted">{portsError}</p> : null}
{discoverMeta ? (
<details className="panel" open style={{ marginBottom: 12 }}>
<summary>
{t("portTraffic.discoverDetail")}
{" · "}
{discoverMeta.ok ? t("portTraffic.discoverOk") : t("portTraffic.discoverFail")}
</summary>
{discoverMeta.command ? (
<p className="muted">
{t("portTraffic.discoverCommand")}: <code>{discoverMeta.command}</code>
</p>
) : null}
{discoverMeta.error ? (
<p className="muted" style={{ color: "var(--danger, #b00)" }}>
{discoverMeta.error}
</p>
) : null}
<p className="muted">{t("portTraffic.discoverRaw")}</p>
<pre className="topo-discover-modal__raw" style={{ maxHeight: 240, overflow: "auto" }}>
{discoverMeta.raw_preview || "—"}
</pre>
</details>
) : null}
{ports.length ? (
<table className="data-table">
<thead>