Ship gated eye polish, fabric levels, and collection UI refresh.

Topology MCP adds pull/compact/bundle/suggest-hubs with a no-template skill path; API gains fabric level and NE collection policy; web list pages get paging and denser collect/network workflows.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-12 16:13:05 +08:00
parent b1aee86701
commit b81e5869a6
91 changed files with 10395 additions and 1513 deletions

View file

@ -1,5 +1,7 @@
import { useMemo, useState } from "react";
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 {
createNeCollection,
deleteCollectionJob,
@ -8,31 +10,51 @@ import {
fetchCollectionRuns,
fetchEligibleNe,
fetchNeCollections,
formatErr,
pauseCollectionJob,
startCollectionJob,
startCollectionFromPolicy,
retryFailedCollectionJob,
updateCollectionPolicy,
collectionJobDownloadUrl,
collectionRunDownloadUrl,
} from "../services/api";
import { queryKeys } from "../constants/queryKeys";
import { useDebouncedValue } from "../hooks/useDebouncedValue";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import type { CollectionJobItem, EligibleNeItem } from "../types";
import type { CollectionJobItem, CollectionRunItem, CollectionTargetRef, EligibleNeItem } from "../types";
import { downloadCsv, fetchAllPages } from "../utils/csvExport";
import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
const POLL_MS = 2000;
const ELIGIBLE_PAGE_SIZE = 20;
const POLICY_TARGET_PAGE_SIZE = 20;
const JOB_STATUS_OPTIONS = ["draft", "pending", "running", "paused", "done", "failed", "cancelled"] as const;
const JOB_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
const RUN_PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
function eligibleKey(row: Pick<EligibleNeItem, "id" | "source">): string {
const src = String(row.source || "managed").trim().toLowerCase() || "managed";
return `${src}:${row.id}`;
}
function targetKey(ref: CollectionTargetRef): string {
const src = String(ref.source || "managed").trim().toLowerCase() || "managed";
return `${src}:${ref.id}`;
}
function collectionErrorMessage(err: unknown, t: (key: string) => string): string {
const raw = String(err);
if (raw.includes("collection_ne_busy")) return t("collect.neBusy");
if (raw.includes("collection_job_running")) return t("collect.jobRunning");
if (raw.includes("commands_empty") || raw.includes("commands_required_for_schedule")) {
return t("collect.policyCommandsRequired");
}
if (raw.includes("no_selected_targets") || raw.includes("no_eligible_ne")) {
return t("collect.policyTargetsRequired");
}
return raw;
}
@ -40,6 +62,7 @@ export function CollectPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const [commands, setCommands] = useState("");
const [title, setTitle] = useState("");
@ -48,12 +71,46 @@ export function CollectPage() {
const [neKeyword, setNeKeyword] = useState("");
const [nePage, setNePage] = useState(1);
const [jobPage, setJobPage] = useState(1);
const [jobPageSize, setJobPageSize] = useState(20);
const [jobStatus, setJobStatus] = useState("");
const [jobKeyword, setJobKeyword] = useState("");
const [exportingJobs, setExportingJobs] = useState(false);
const [expandedJobId, setExpandedJobId] = useState("");
const [createOpen, setCreateOpen] = useState(false);
const [policyEnabled, setPolicyEnabled] = useState(false);
const [policyIntervalValue, setPolicyIntervalValue] = useState(1);
const [policyIntervalUnit, setPolicyIntervalUnit] = useState<"days" | "hours">("days");
const [policyHistoryKeep, setPolicyHistoryKeep] = useState(3);
const [policyScopeMode, setPolicyScopeMode] = useState<"all" | "selected">("all");
const [policyTitle, setPolicyTitle] = useState("");
const [policyCommands, setPolicyCommands] = useState("");
const [policySelectedMap, setPolicySelectedMap] = useState<Record<string, CollectionTargetRef>>({});
const [policyHydrated, setPolicyHydrated] = useState(false);
const [policyTargetKeyword, setPolicyTargetKeyword] = useState("");
const [policyTargetPage, setPolicyTargetPage] = useState(1);
const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
const debouncedPolicyTargetKeyword = useDebouncedValue(policyTargetKeyword, 300);
const selectedIds = useMemo(() => Object.keys(selectedMap), [selectedMap]);
const selectedList = useMemo(() => Object.values(selectedMap), [selectedMap]);
// Deep link: /collect?job_id=… → open runs modal, then strip param
useEffect(() => {
const jobId = String(searchParams.get("job_id") || "").trim();
if (!jobId) return;
setExpandedJobId(jobId);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (next.get("job_id") === jobId) next.delete("job_id");
return next;
},
{ replace: true },
);
}, [searchParams, setSearchParams]);
const eligibleQuery = useQuery({
queryKey: queryKeys.collectionEligibleNe(nePage, neKeyword),
queryFn: () =>
@ -63,8 +120,14 @@ export function CollectPage() {
});
const jobsQuery = useQuery({
queryKey: queryKeys.neCollections(jobPage),
queryFn: () => fetchNeCollections({ page: jobPage, pageSize: 20 }),
queryKey: queryKeys.neCollections(jobPage, jobPageSize, jobStatus, debouncedJobKeyword),
queryFn: () =>
fetchNeCollections({
page: jobPage,
pageSize: jobPageSize,
status: jobStatus,
keyword: debouncedJobKeyword,
}),
staleTime: 1000,
refetchInterval: (q) => {
const items = q.state.data?.items ?? [];
@ -85,6 +148,42 @@ export function CollectPage() {
},
});
useEffect(() => {
const p = dashQuery.data?.policy;
if (!p || policyHydrated) return;
setPolicyEnabled(Boolean(p.enabled));
const hours = Math.max(1, Number(p.interval_hours || Number(p.interval_days || 1) * 24));
if (hours % 24 === 0) {
setPolicyIntervalUnit("days");
setPolicyIntervalValue(Math.max(1, Math.min(365, hours / 24)));
} else {
setPolicyIntervalUnit("hours");
setPolicyIntervalValue(Math.max(1, Math.min(8760, hours)));
}
setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(p.history_keep ?? 3))));
setPolicyScopeMode(p.scope_mode === "selected" ? "selected" : "all");
setPolicyTitle(String(p.title || ""));
setPolicyCommands(String(p.commands || ""));
const map: Record<string, CollectionTargetRef> = {};
for (const ref of p.selected_targets || []) {
map[targetKey(ref)] = { source: ref.source, id: ref.id };
}
setPolicySelectedMap(map);
setPolicyHydrated(true);
}, [dashQuery.data?.policy, policyHydrated]);
const policyTargetsQuery = useQuery({
queryKey: queryKeys.collectionEligibleNe(policyTargetPage, debouncedPolicyTargetKeyword),
queryFn: () =>
fetchEligibleNe({
page: policyTargetPage,
pageSize: POLICY_TARGET_PAGE_SIZE,
keyword: debouncedPolicyTargetKeyword,
}),
staleTime: 5000,
enabled: policyScopeMode === "selected",
});
const detailQuery = useQuery({
queryKey: queryKeys.neCollectionDetail(expandedJobId),
queryFn: () => fetchCollectionJob(expandedJobId),
@ -121,6 +220,62 @@ export function CollectPage() {
]);
};
const savePolicyMutation = useMutation({
mutationFn: () => {
const hours =
policyIntervalUnit === "days"
? Math.max(1, Math.min(365, policyIntervalValue)) * 24
: Math.max(1, Math.min(8760, policyIntervalValue));
return updateCollectionPolicy({
enabled: policyEnabled,
interval_hours: hours,
history_keep: policyHistoryKeep,
scope_mode: policyScopeMode,
title: policyTitle.trim(),
commands: policyCommands,
selected_targets: Object.values(policySelectedMap),
});
},
onSuccess: async (saved) => {
setPolicyEnabled(Boolean(saved.enabled));
const hours = Math.max(1, Number(saved.interval_hours || Number(saved.interval_days || 1) * 24));
if (hours % 24 === 0) {
setPolicyIntervalUnit("days");
setPolicyIntervalValue(Math.max(1, Math.min(365, hours / 24)));
} else {
setPolicyIntervalUnit("hours");
setPolicyIntervalValue(Math.max(1, Math.min(8760, hours)));
}
setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(saved.history_keep ?? 3))));
setPolicyScopeMode(saved.scope_mode === "selected" ? "selected" : "all");
setPolicyTitle(String(saved.title || ""));
setPolicyCommands(String(saved.commands || ""));
const map: Record<string, CollectionTargetRef> = {};
for (const ref of saved.selected_targets || []) {
map[targetKey(ref)] = { source: ref.source, id: ref.id };
}
setPolicySelectedMap(map);
setPolicyHydrated(true);
queryClient.setQueryData(queryKeys.neCollectionDashboard, (prev: unknown) => {
if (!prev || typeof prev !== "object") return prev;
return { ...(prev as object), policy: saved };
});
showOk(t("collect.policySaved"));
await refreshAll();
},
onError: (err) => showError(collectionErrorMessage(err, t)),
});
const startFromPolicyMutation = useMutation({
mutationFn: startCollectionFromPolicy,
onSuccess: async (job) => {
showOk(t("collect.started", { id: job.id }));
setExpandedJobId(job.id);
await refreshAll();
},
onError: (err) => showError(collectionErrorMessage(err, t)),
});
const invalidateJobs = async (jobId?: string) => {
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionsAll });
await queryClient.invalidateQueries({ queryKey: queryKeys.neCollectionDashboard });
@ -231,7 +386,58 @@ export function CollectPage() {
const nePages = pageCount(neTotal, ELIGIBLE_PAGE_SIZE);
const jobTotal = jobsQuery.data?.total ?? 0;
const jobPages = pageCount(jobTotal, 20);
const jobPages = pageCount(jobTotal, jobPageSize);
const hasJobFilters = Boolean(jobKeyword.trim() || jobStatus);
const exportJobsCsv = async () => {
setExportingJobs(true);
try {
const rows = await fetchAllPages<CollectionJobItem>({
pageSize: 100,
maxRows: 2000,
fetchPage: (page, pageSize) =>
fetchNeCollections({
page,
pageSize,
status: jobStatus,
keyword: debouncedJobKeyword,
}),
});
downloadCsv(
`${t("collect.exportJobsName")}-${new Date().toISOString().slice(0, 10)}.csv`,
rows,
[
{ key: "title", header: t("collect.jobs.col.title") },
{ key: "status", header: t("collect.jobs.col.status") },
{
key: "progress",
header: t("collect.jobs.col.progress"),
value: (r) =>
`${r.success_count}/${r.ne_count} ${t("collect.jobs.ok")}, ${r.fail_count} ${t("collect.jobs.fail")}`,
},
{
key: "started_at",
header: "started_at",
value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""),
},
{
key: "ended_at",
header: "ended_at",
value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""),
},
],
);
if (rows.length < jobTotal) {
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(jobTotal) }));
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExportingJobs(false);
}
};
const commandLines = useMemo(
() =>
@ -262,10 +468,25 @@ export function CollectPage() {
) : null}
</div>
<div className="btn-row">
<button
type="button"
disabled={exportingJobs || jobTotal === 0}
onClick={() => void exportJobsCsv()}
>
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
</button>
<button type="button" onClick={refreshAll} disabled={jobsQuery.isFetching || dashQuery.isFetching}>
{jobsQuery.isFetching || dashQuery.isFetching ? t("common.refreshing") : t("common.refresh")}
</button>
<button type="button" className="btn-primary" onClick={() => setCreateOpen(true)}>
<button
type="button"
className="btn-primary"
disabled={startFromPolicyMutation.isPending || jobActive}
onClick={() => startFromPolicyMutation.mutate()}
>
{startFromPolicyMutation.isPending ? t("collect.form.starting") : t("collect.collectNow")}
</button>
<button type="button" onClick={() => setCreateOpen(true)}>
{t("collect.create.expand")}
</button>
{running?.status === "running" || running?.status === "pending" ? (
@ -294,10 +515,6 @@ export function CollectPage() {
: t("collect.kpi.idle")}
</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("collect.kpi.active")}</div>
<div className="pt-list-kpi__value">{dash?.active_count ?? "—"}</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("collect.kpi.last")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
@ -306,10 +523,225 @@ export function CollectPage() {
: t("common.empty")}
</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">{t("collect.kpi.nextDue")}</div>
<div className="pt-list-kpi__value" style={{ fontSize: 15 }}>
{dash?.next_due_at ? formatSystemTime(dash.next_due_at) : t("common.empty")}
</div>
</div>
</div>
</div>
</section>
<section className="panel" style={{ marginBottom: 16 }}>
<h3>{t("collect.policyTitle")}</h3>
<p className="muted" style={{ marginTop: 0 }}>
{t("collect.policyHint")}
</p>
<div className="config-sync-policy-row">
<label className="config-sync-policy-check">
<input
type="checkbox"
checked={policyEnabled}
onChange={(e) => setPolicyEnabled(e.target.checked)}
/>
<span>{t("collect.enabled")}</span>
</label>
<label className="config-sync-policy-field">
<span>{t("collect.interval")}</span>
<input
type="number"
min={1}
max={policyIntervalUnit === "days" ? 365 : 8760}
value={policyIntervalValue}
onChange={(e) => {
const max = policyIntervalUnit === "days" ? 365 : 8760;
setPolicyIntervalValue(Math.max(1, Math.min(max, Number(e.target.value) || 1)));
}}
/>
<select
value={policyIntervalUnit}
onChange={(e) => {
const next = e.target.value === "hours" ? "hours" : "days";
if (next === policyIntervalUnit) return;
if (next === "hours") {
setPolicyIntervalValue(Math.max(1, Math.min(8760, policyIntervalValue * 24)));
} else {
setPolicyIntervalValue(
Math.max(1, Math.min(365, Math.round(policyIntervalValue / 24) || 1)),
);
}
setPolicyIntervalUnit(next);
}}
>
<option value="days">{t("collect.intervalUnitDays")}</option>
<option value="hours">{t("collect.intervalUnitHours")}</option>
</select>
</label>
<label className="config-sync-policy-field">
<span>{t("collect.historyKeep")}</span>
<input
type="number"
min={0}
max={200}
value={policyHistoryKeep}
onChange={(e) =>
setPolicyHistoryKeep(Math.max(0, Math.min(200, Number(e.target.value) || 0)))
}
/>
</label>
<label className="config-sync-policy-field">
<span>{t("collect.scope")}</span>
<select
value={policyScopeMode}
onChange={(e) => setPolicyScopeMode(e.target.value === "selected" ? "selected" : "all")}
>
<option value="all">{t("collect.scopeAll")}</option>
<option value="selected">{t("collect.scopeSelected")}</option>
</select>
</label>
<button
type="button"
className="btn-primary"
disabled={savePolicyMutation.isPending}
onClick={() => savePolicyMutation.mutate()}
>
{t("collect.savePolicy")}
</button>
</div>
<div className="filter-inline" style={{ marginTop: 12 }}>
<label className="config-sync-policy-field" style={{ flex: 1 }}>
<span>{t("collect.form.jobTitle")}</span>
<input
value={policyTitle}
placeholder={t("collect.form.jobTitlePh")}
onChange={(e) => setPolicyTitle(e.target.value)}
/>
</label>
</div>
<label className="config-sync-policy-field" style={{ display: "block", marginTop: 12 }}>
<span>{t("collect.form.commands")}</span>
<textarea
rows={5}
value={policyCommands}
placeholder={t("collect.form.commandsPh")}
onChange={(e) => setPolicyCommands(e.target.value)}
style={{ width: "100%", fontFamily: "var(--font-mono, monospace)" }}
/>
<span className="muted">{t("collect.form.commandsHint")}</span>
</label>
{policyScopeMode === "selected" ? (
<div style={{ marginTop: 12 }}>
<p className="muted">
{t("collect.selectedCount", { count: String(Object.keys(policySelectedMap).length) })}
</p>
<div className="filter-inline">
<input
value={policyTargetKeyword}
placeholder={t("collect.create.filterKeywordPh")}
onChange={(e) => {
setPolicyTargetKeyword(e.target.value);
setPolicyTargetPage(1);
}}
/>
</div>
<table className="data-table">
<thead>
<tr>
<th />
<th>{t("collect.create.pickTitle")}</th>
<th>IP</th>
<th>{t("collect.colSource")}</th>
</tr>
</thead>
<tbody>
{(policyTargetsQuery.data?.items ?? []).map((row) => {
const key = eligibleKey(row);
const checked = Boolean(policySelectedMap[key]);
return (
<tr key={key}>
<td>
<input
type="checkbox"
checked={checked}
onChange={() => {
setPolicySelectedMap((prev) => {
const next = { ...prev };
if (next[key]) delete next[key];
else next[key] = { source: row.source || "managed", id: row.id };
return next;
});
}}
/>
</td>
<td>{row.name || row.id}</td>
<td>{row.ip_address}</td>
<td>{row.source}</td>
</tr>
);
})}
</tbody>
</table>
<ListPager
page={policyTargetPage}
pages={pageCount(policyTargetsQuery.data?.total ?? 0, POLICY_TARGET_PAGE_SIZE)}
total={policyTargetsQuery.data?.total ?? 0}
pageSize={POLICY_TARGET_PAGE_SIZE}
onPageChange={setPolicyTargetPage}
/>
</div>
) : null}
</section>
<section className="panel">
<div className="panel__toolbar">
<div>
<h2>{t("collect.jobs.listTitle")}</h2>
</div>
</div>
<div className="pt-list">
<div className="filter-inline">
<input
type="search"
value={jobKeyword}
placeholder={t("collect.keywordPh")}
onChange={(e) => {
setJobKeyword(e.target.value);
setJobPage(1);
}}
/>
<select
value={jobStatus}
onChange={(e) => {
setJobStatus(e.target.value);
setJobPage(1);
}}
>
<option value="">{t("collect.statusAll")}</option>
{JOB_STATUS_OPTIONS.map((st) => (
<option key={st} value={st}>
{st}
</option>
))}
</select>
<button
type="button"
disabled={!hasJobFilters}
onClick={() => {
setJobKeyword("");
setJobStatus("");
setJobPage(1);
}}
>
{t("common.clearFilters")}
</button>
</div>
{jobsQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{jobsQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!(jobsQuery.data?.items ?? []).length && !jobsQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("common.empty")}</p>
@ -347,23 +779,19 @@ export function CollectPage() {
</table>
</div>
)}
<div className="pager pt-list-pager">
<div className="pager__meta muted">
{t("common.pagerMeta", {
total: String(jobTotal),
page: String(jobPage),
pages: String(jobPages),
})}
</div>
<div className="pager__controls btn-row">
<button className="pager__btn" disabled={jobPage <= 1} onClick={() => setJobPage(jobPage - 1)}>
{t("common.prevPage")}
</button>
<button className="pager__btn" disabled={jobPage >= jobPages} onClick={() => setJobPage(jobPage + 1)}>
{t("common.nextPage")}
</button>
</div>
</div>
<ListPager
page={jobPage}
pages={jobPages}
total={jobTotal}
pageSize={jobPageSize}
pageSizeOptions={JOB_PAGE_SIZE_OPTIONS}
onPageChange={setJobPage}
onPageSizeChange={(size) => {
setJobPageSize(size);
setJobPage(1);
}}
disabled={jobsQuery.isLoading}
/>
</div>
</section>
@ -764,7 +1192,6 @@ function JobRow({
);
}
const RUN_PAGE_SIZE = 20;
const RUN_STATUS_OPTIONS = ["pending", "running", "success", "fail", "cancelled"] as const;
function JobRunsPanel({
@ -783,19 +1210,23 @@ function JobRunsPanel({
retryPending: boolean;
}) {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [runPage, setRunPage] = useState(1);
const [runPageSize, setRunPageSize] = useState(20);
const [runStatus, setRunStatus] = useState("");
const [runKeyword, setRunKeyword] = useState("");
const [exportingRuns, setExportingRuns] = useState(false);
const debouncedRunKeyword = useDebouncedValue(runKeyword, 300);
const runsQuery = useQuery({
queryKey: queryKeys.neCollectionRuns(jobId, runPage, runStatus, runKeyword),
queryKey: queryKeys.neCollectionRuns(jobId, runPage, runPageSize, runStatus, debouncedRunKeyword),
queryFn: () =>
fetchCollectionRuns({
jobId,
page: runPage,
pageSize: RUN_PAGE_SIZE,
pageSize: runPageSize,
status: runStatus,
keyword: runKeyword,
keyword: debouncedRunKeyword,
}),
staleTime: 500,
refetchInterval: jobStatus === "running" ? 2000 : false,
@ -808,8 +1239,57 @@ function JobRunsPanel({
};
const runTotal = runsQuery.data?.total ?? 0;
const runPages = pageCount(runTotal, RUN_PAGE_SIZE);
const runPages = pageCount(runTotal, runPageSize);
const runs = runsQuery.data?.items ?? [];
const hasRunFilters = Boolean(runStatus || runKeyword.trim());
const exportRunsCsv = async () => {
setExportingRuns(true);
try {
const rows = await fetchAllPages<CollectionRunItem>({
pageSize: 100,
maxRows: 2000,
fetchPage: (page, pageSize) =>
fetchCollectionRuns({
jobId,
page,
pageSize,
status: runStatus,
keyword: debouncedRunKeyword,
}),
});
downloadCsv(
`${t("collect.exportRunsName")}-${jobId.slice(0, 8)}-${new Date().toISOString().slice(0, 10)}.csv`,
rows,
[
{ key: "ne_source", header: t("managedNe.col.source"), value: (r) => r.ne_source || "managed" },
{ key: "ne_name", header: t("managedNe.col.name") },
{ key: "ne_ip", header: t("managedNe.col.ip") },
{ key: "status", header: t("collect.runs.status") },
{ key: "message", header: t("collect.runs.message") },
{
key: "started_at",
header: "started_at",
value: (r) => (r.started_at ? formatSystemTime(r.started_at) : ""),
},
{
key: "ended_at",
header: "ended_at",
value: (r) => (r.ended_at ? formatSystemTime(r.ended_at) : ""),
},
],
);
if (rows.length < runTotal) {
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(runTotal) }));
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExportingRuns(false);
}
};
return (
<>
@ -841,11 +1321,14 @@ function JobRunsPanel({
</option>
))}
</select>
{runStatus || runKeyword ? (
{hasRunFilters ? (
<button type="button" onClick={clearRunFilters}>
{t("common.clearFilters")}
</button>
) : null}
<button type="button" disabled={exportingRuns || runTotal === 0} onClick={() => void exportRunsCsv()}>
{exportingRuns ? t("common.exporting") : t("common.exportCsv")}
</button>
{jobStatus !== "running" && failCount > 0 ? (
<button type="button" disabled={retryPending} onClick={onRetryFailed}>
{t("collect.jobs.retryFailed")}
@ -907,21 +1390,19 @@ function JobRunsPanel({
</div>
</div>
<div className="ops-detail-modal__foot">
<span className="muted">
{t("common.pagerMeta", {
total: String(runTotal),
page: String(runPage),
pages: String(runPages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={runPage <= 1} onClick={() => setRunPage(runPage - 1)}>
{t("common.prevPage")}
</button>
<button type="button" disabled={runPage >= runPages} onClick={() => setRunPage(runPage + 1)}>
{t("common.nextPage")}
</button>
</div>
<ListPager
page={runPage}
pages={runPages}
total={runTotal}
pageSize={runPageSize}
pageSizeOptions={RUN_PAGE_SIZE_OPTIONS}
onPageChange={setRunPage}
onPageSizeChange={(size) => {
setRunPageSize(size);
setRunPage(1);
}}
disabled={runsQuery.isLoading}
/>
</div>
</>
);

View file

@ -1,20 +1,25 @@
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 { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import type { CliTargetItem, ConfigSyncTargetRef } from "../types";
import type { CliTargetItem, ConfigSyncCycle, ConfigSyncTargetRef, ConfigSyncTask } from "../types";
import { downloadCsv, fetchAllPages } from "../utils/csvExport";
import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
@ -25,12 +30,22 @@ 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);
@ -73,22 +88,52 @@ export function ConfigSyncPage() {
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),
queryFn: () => fetchConfigSyncCycles({ page: cyclePage, pageSize: 10 }),
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, taskStatus, taskKeyword),
queryKey: queryKeys.configSyncCycleTasks(
expandedCycleId,
taskPage,
taskPageSize,
taskStatus,
debouncedTaskKeyword,
),
queryFn: () =>
fetchConfigSyncCycleTasks({
cycleId: expandedCycleId,
page: taskPage,
pageSize: 20,
pageSize: taskPageSize,
status: taskStatus,
keyword: taskKeyword,
keyword: debouncedTaskKeyword,
}),
enabled: Boolean(expandedCycleId),
staleTime: 800,
@ -144,7 +189,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.policySaved"));
await refresh();
},
onError: (err) => showError(String(err)),
onError: (err) => showError(formatErr(err)),
});
const startMut = useMutation({
@ -153,7 +198,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.started"));
await refresh();
},
onError: (err) => showError(String(err)),
onError: (err) => showError(formatErr(err)),
});
const pauseMut = useMutation({
@ -162,7 +207,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.paused"));
await refresh();
},
onError: (err) => showError(String(err)),
onError: (err) => showError(formatErr(err)),
});
const resumeMut = useMutation({
@ -171,7 +216,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.resumed"));
await refresh();
},
onError: (err) => showError(String(err)),
onError: (err) => showError(formatErr(err)),
});
const stopMut = useMutation({
@ -180,7 +225,7 @@ export function ConfigSyncPage() {
showOk(t("configSync.stopped"));
await refresh();
},
onError: (err) => showError(String(err)),
onError: (err) => showError(formatErr(err)),
});
const dash = dashQuery.data;
@ -188,8 +233,110 @@ export function ConfigSyncPage() {
const last = dash?.last_cycle;
const cycles = cyclesQuery.data?.items ?? [];
const cycleTotal = Number(cyclesQuery.data?.total || 0);
const cyclePages = pageCount(cycleTotal, 10);
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<ConfigSyncCycle>({
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<ConfigSyncTask>({
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";
@ -416,114 +563,155 @@ export function ConfigSyncPage() {
</div>
<h3>{t("configSync.cyclesTitle")}</h3>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th />
<th>ID</th>
<th>{t("configSync.col.trigger")}</th>
<th>{t("configSync.col.status")}</th>
<th>{t("configSync.col.progress")}</th>
<th>{t("configSync.col.started")}</th>
<th>{t("configSync.col.ended")}</th>
<th>{t("configSync.col.actions")}</th>
</tr>
</thead>
<tbody>
{cycles.map((c) => (
<tr key={c.id}>
<td>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
setExpandedCycleId(c.id);
setTaskPage(1);
setTaskKeyword("");
setTaskStatus("");
}}
>
{t("configSync.expand")}
</button>
</td>
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
<td>{c.trigger_mode}</td>
<td>
<span
className={`pt-list-status ${
c.status === "running" || c.status === "pending"
? "pt-list-status--running"
: c.status === "paused"
? "pt-list-status--paused"
: c.status === "failed"
? "pt-list-status--failed"
: c.status === "success" || c.status === "completed"
? "pt-list-status--ok"
: "pt-list-status--other"
}`}
>
{c.status}
</span>
</td>
<td>
{c.success_count}/{c.planned_count} · fail {c.fail_count}
</td>
<td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td>
<td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td>
<td>
<div className="btn-row">
{c.status === "running" || c.status === "pending" ? (
<button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
{t("configSync.pause")}
</button>
) : null}
{c.status === "paused" ? (
<button type="button" onClick={() => resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
{t("configSync.resume")}
</button>
) : null}
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
<div className="pt-list">
<div className="filter-inline">
<input
value={cycleKeyword}
placeholder={t("configSync.keywordPh")}
onChange={(e) => {
setCycleKeyword(e.target.value);
setCyclePage(1);
}}
/>
<select
value={cycleStatus}
onChange={(e) => {
setCycleStatus(e.target.value);
setCyclePage(1);
}}
>
<option value="">{t("configSync.statusAll")}</option>
<option value="pending">pending</option>
<option value="running">running</option>
<option value="paused">paused</option>
<option value="success">success</option>
<option value="completed">completed</option>
<option value="failed">failed</option>
<option value="stopped">stopped</option>
</select>
<button
type="button"
disabled={!hasCycleFilters}
onClick={() => {
setCycleKeyword("");
setCycleStatus("");
setCyclePage(1);
}}
>
{t("common.clearFilters")}
</button>
<button
type="button"
disabled={exportingCycles || cycleTotal === 0}
onClick={() => void exportCyclesCsv()}
>
{exportingCycles ? t("common.exporting") : t("common.exportCsv")}
</button>
</div>
{cyclesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{cyclesQuery.isError ? <p className="error-text">{formatErr(cyclesQuery.error)}</p> : null}
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th />
<th>ID</th>
<th>{t("configSync.col.trigger")}</th>
<th>{t("configSync.col.status")}</th>
<th>{t("configSync.col.progress")}</th>
<th>{t("configSync.col.started")}</th>
<th>{t("configSync.col.ended")}</th>
<th>{t("configSync.col.actions")}</th>
</tr>
</thead>
<tbody>
{cycles.map((c) => (
<tr key={c.id}>
<td>
<button
type="button"
onClick={() => {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
}}
disabled={stopMut.isPending}
className="btn btn--sm btn--ghost"
onClick={() => openCycleTasks(c.id)}
>
{t("configSync.stop")}
{t("configSync.expand")}
</button>
) : null}
</div>
</td>
</tr>
))}
{!cycles.length ? (
<tr>
<td colSpan={8} className="muted">
{t("configSync.cyclesEmpty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<div className="pager pt-list-pager">
<span className="muted">
{t("common.pagerMeta", {
total: String(cycleTotal),
page: String(cyclePage),
pages: String(cyclePages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={cyclePage <= 1} onClick={() => setCyclePage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<button type="button" disabled={cyclePage >= cyclePages} onClick={() => setCyclePage((p) => p + 1)}>
{t("common.nextPage")}
</button>
</td>
<td title={c.id} className="pt-list-num">{c.id.slice(0, 8)}</td>
<td>{c.trigger_mode}</td>
<td>
<span
className={`pt-list-status ${
c.status === "running" || c.status === "pending"
? "pt-list-status--running"
: c.status === "paused"
? "pt-list-status--paused"
: c.status === "failed"
? "pt-list-status--failed"
: c.status === "success" || c.status === "completed"
? "pt-list-status--ok"
: "pt-list-status--other"
}`}
>
{c.status}
</span>
</td>
<td>
{c.success_count}/{c.planned_count} · fail {c.fail_count}
</td>
<td>{c.started_at ? formatSystemTime(c.started_at) : "-"}</td>
<td>{c.ended_at ? formatSystemTime(c.ended_at) : "-"}</td>
<td>
<div className="btn-row">
{c.status === "running" || c.status === "pending" ? (
<button type="button" onClick={() => pauseMut.mutate(c.id)} disabled={pauseMut.isPending}>
{t("configSync.pause")}
</button>
) : null}
{c.status === "paused" ? (
<button type="button" onClick={() => resumeMut.mutate(c.id)} disabled={resumeMut.isPending}>
{t("configSync.resume")}
</button>
) : null}
{c.status === "running" || c.status === "paused" || c.status === "pending" ? (
<button
type="button"
onClick={() => {
if (window.confirm(t("configSync.confirmStop"))) stopMut.mutate(c.id);
}}
disabled={stopMut.isPending}
>
{t("configSync.stop")}
</button>
) : null}
</div>
</td>
</tr>
))}
{!cycles.length && !cyclesQuery.isLoading ? (
<tr>
<td colSpan={8} className="muted">
{t("configSync.cyclesEmpty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
<ListPager
page={cyclePage}
pages={cyclePages}
total={cycleTotal}
pageSize={cyclePageSize}
pageSizeOptions={[20, 50, 100]}
onPageChange={setCyclePage}
onPageSizeChange={(size) => {
setCyclePageSize(size);
setCyclePage(1);
}}
disabled={cyclesQuery.isLoading}
/>
</div>
{expandedCycleId ? (
@ -553,6 +741,13 @@ export function ConfigSyncPage() {
</p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button
type="button"
disabled={exportingTasks || taskTotal === 0}
onClick={() => void exportTasksCsv()}
>
{exportingTasks ? t("common.exporting") : t("common.exportCsv")}
</button>
<button type="button" onClick={() => setExpandedCycleId("")}>
{t("networkConfigs.close")}
</button>
@ -575,7 +770,7 @@ export function ConfigSyncPage() {
setTaskPage(1);
}}
>
<option value="">{t("configSync.allStatus")}</option>
<option value="">{t("configSync.statusAll")}</option>
<option value="pending">pending</option>
<option value="running">running</option>
<option value="success">success</option>
@ -586,9 +781,7 @@ export function ConfigSyncPage() {
{tasksQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{tasksQuery.isError ? (
<p className="ops-detail-modal__error">
{tasksQuery.error instanceof Error ? tasksQuery.error.message : String(tasksQuery.error)}
</p>
<p className="ops-detail-modal__error">{formatErr(tasksQuery.error)}</p>
) : null}
<div className="ops-detail-modal__scroll">
@ -628,25 +821,19 @@ export function ConfigSyncPage() {
</div>
<div className="ops-detail-modal__foot">
<span className="muted">
{t("common.pagerMeta", {
total: String(tasksQuery.data?.total ?? 0),
page: String(taskPage),
pages: String(pageCount(Number(tasksQuery.data?.total || 0), 20)),
})}
</span>
<div className="btn-row">
<button type="button" disabled={taskPage <= 1} onClick={() => setTaskPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<button
type="button"
disabled={taskPage >= pageCount(Number(tasksQuery.data?.total || 0), 20)}
onClick={() => setTaskPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
<ListPager
page={taskPage}
pages={taskPages}
total={taskTotal}
pageSize={taskPageSize}
pageSizeOptions={[20, 50, 100, 200]}
onPageChange={setTaskPage}
onPageSizeChange={(size) => {
setTaskPageSize(size);
setTaskPage(1);
}}
disabled={tasksQuery.isLoading}
/>
</div>
</div>
</div>

View file

@ -89,6 +89,8 @@ import {
import { queryKeys } from "../constants/queryKeys";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { useAuth } from "../auth/AuthContext";
import { formatErr } from "../services/api";
import { openOrFocusModule } from "../utils/moduleWindows";
import type {
FabricNodeSearchHit,
@ -114,6 +116,8 @@ const TopologyReactFlowView = lazy(() => import("./topology/TopologyReactFlowVie
export function TopologyPage() {
const { t, locale } = useI18n();
const { showOk, showError } = useToast();
const { hasScope, isAdmin } = useAuth();
const canWriteTopology = isAdmin || hasScope("ne:write");
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const [keyword, setKeyword] = useState("");
@ -295,7 +299,13 @@ export function TopologyPage() {
return () => window.removeEventListener("beforeunload", onBeforeUnload);
}, []);
const toolBehavior = useMemo(() => behaviorForMode(toolMode), [toolMode]);
const toolBehavior = useMemo(() => {
const base = behaviorForMode(toolMode);
if (!canWriteTopology) {
return { ...base, nodesDraggable: false, nodesConnectable: false };
}
return base;
}, [toolMode, canWriteTopology]);
const canvas = useTopologyCanvas({
mapId,
@ -354,6 +364,8 @@ export function TopologyPage() {
worldScatter,
showWorldScatter,
canvasGraphLoading,
canvasGraphError,
canvasGraphErrorMsg,
canvasGraphEmpty,
worldNeedsApply,
canvasGraphRefreshing,
@ -537,7 +549,10 @@ export function TopologyPage() {
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyMaps });
showOk(t("topology.saved"));
},
onError: (err) => showError(String(err)),
onError: async (err) => {
showError(t("topology.saveFailed").replace("{{detail}}", formatErr(err)));
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyGraph(mapId) });
},
});
const isValidConnection = useCallback(
@ -545,14 +560,18 @@ export function TopologyPage() {
const source = String(connection.source || "");
const target = String(connection.target || "");
if (!source || !target || source === target) return false;
return !edges.some((e) => e.source === source && e.target === target);
return !edges.some(
(e) =>
(e.source === source && e.target === target) ||
(e.source === target && e.target === source),
);
},
[edges],
);
const onConnect = useCallback(
(connection: Connection) => {
if (!mapId || !isValidConnection(connection)) return;
if (!canWriteTopology || !mapId || !isValidConnection(connection)) return;
const source = String(connection.source || "");
const target = String(connection.target || "");
if (!source || !target) return;
@ -586,11 +605,12 @@ export function TopologyPage() {
setEdges((es) => [...es, edge]);
markDirty();
},
[mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty],
[mapId, isValidConnection, pushHistory, edgeDefaults, setEdges, markDirty, canWriteTopology],
);
const addPaletteItems = useCallback(
async (items: PaletteItem[], origin?: { x: number; y: number }): Promise<boolean> => {
if (!canWriteTopology) return false;
if (!mapId) {
showError(t("topology.selectMap"));
return false;
@ -700,6 +720,7 @@ export function TopologyPage() {
const onCanvasDrop = (e: React.DragEvent) => {
e.preventDefault();
if (!canWriteTopology) return;
const raw = e.dataTransfer.getData(PALETTE_DND);
if (!raw || !rfRef.current) return;
try {
@ -1076,7 +1097,7 @@ export function TopologyPage() {
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "s") {
e.preventDefault();
if (mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate();
if (canWriteTopology && mapId && dirtyRef.current && !saveMut.isPending) saveMut.mutate();
return;
}
if ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "z" && !e.shiftKey) {
@ -1095,6 +1116,7 @@ export function TopologyPage() {
return;
}
if (e.key === "Delete" || e.key === "Backspace") {
if (!canWriteTopology) return;
if (nodes.some((n) => n.selected) || edges.some((ed) => ed.selected)) {
e.preventDefault();
void removeSelected();
@ -1103,7 +1125,7 @@ export function TopologyPage() {
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges, mapId, saveMut]);
}, [closeCtxMenu, clearSelection, undo, redo, selectAllNodes, removeSelected, nodes, edges, mapId, saveMut, canWriteTopology]);
useEffect(() => {
if (!ctxMenu) return;
@ -1366,6 +1388,10 @@ export function TopologyPage() {
const discoverOneFor = (node: Node<NeNodeData> | null) => {
closeCtxMenu();
if (!canWriteTopology) {
showError(t("topology.readOnlyHint"));
return;
}
const id = String(node?.data.managed_ne_id || node?.data.ume_ne_id || "").trim();
if (!id) {
showError(t("topology.discoverOneNeedNe"));
@ -1917,6 +1943,7 @@ export function TopologyPage() {
<main className="topo-main">
{canvasMode ? (
<TopologyToolbar
readOnly={!canWriteTopology}
breadcrumbFolders={breadcrumbFolders}
activeView={activeView}
activeRegion={activeRegion}
@ -2051,6 +2078,11 @@ export function TopologyPage() {
{truncateBannerText}
</div>
) : null}
{!canWriteTopology && mapId ? (
<div className="topo-readonly-banner" role="status">
{t("topology.readOnlyBanner")}
</div>
) : null}
<TopologyViewTools
fullscreen={fullscreen}
toolbarSlot={viewToolsToolbarSlot}
@ -2248,6 +2280,23 @@ export function TopologyPage() {
</button>
</div>
)}
{treeRoot && canvasGraphError ? (
<div className="topo-canvas__overlay topo-canvas__overlay--error" role="alert">
<p className="topo-canvas__overlay-title">{t("topology.graphLoadFailed")}</p>
{canvasGraphErrorMsg ? (
<p className="topo-canvas__overlay-hint muted">{canvasGraphErrorMsg}</p>
) : null}
<div className="topo-browser__empty-actions">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => void graphQuery.refetch()}
>
{t("topology.treeRetry")}
</button>
</div>
</div>
) : null}
{treeRoot && canvasGraphLoading ? (
<div className="topo-canvas__overlay topo-canvas__overlay--loading" role="status">
<span className="topo-loading-spinner topo-loading-spinner--lg" aria-hidden="true" />

View file

@ -1,23 +1,33 @@
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 { HelpHint } from "../../components/HelpHint";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import {
fetchCliTargets,
fetchFabricEdges,
fetchLldpCollectDashboard,
fetchLldpCollectJob,
fetchLldpCollectJobs,
formatErr,
pauseLldpCollectJob,
resumeLldpCollectJob,
startLldpCollect,
stopLldpCollectJob,
updateLldpCollectPolicy,
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { HelpHint } from "../../components/HelpHint";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import type { CliTargetItem, ConfigSyncTargetRef, TopologyDiscoverJobItem } from "../../types";
import type {
CliTargetItem,
ConfigSyncTargetRef,
FabricEdge,
LldpCollectJobSummary,
TopologyDiscoverJobItem,
} from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
@ -89,15 +99,27 @@ export function LldpLinksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [jobPage, setJobPage] = useState(1);
const [jobPageSize, setJobPageSize] = useState(20);
const [jobStatus, setJobStatus] = useState("");
const [jobKeyword, setJobKeyword] = useState("");
const [exportingJobs, setExportingJobs] = useState(false);
const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
const [expandedJobId, setExpandedJobId] = useState("");
const [itemPage, setItemPage] = useState(1);
const [itemPageSize, setItemPageSize] = useState(20);
const [itemStatus, setItemStatus] = useState("");
const [itemKeyword, setItemKeyword] = useState("");
const [exportingItems, setExportingItems] = useState(false);
const debouncedItemKeyword = useDebouncedValue(itemKeyword, 300);
const [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
const ITEM_PAGE_SIZE = 20;
const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
const [edgeSource, setEdgeSource] = useState("");
const [edgeKeyword, setEdgeKeyword] = useState("");
const [edgePage, setEdgePage] = useState(1);
const EDGE_PAGE_SIZE = 20;
const [edgePageSize, setEdgePageSize] = useState(50);
const [exportingEdges, setExportingEdges] = useState(false);
const debouncedEdgeKeyword = useDebouncedValue(edgeKeyword, 300);
const [enabled, setEnabled] = useState(false);
const [intervalValue, setIntervalValue] = useState(1);
@ -156,6 +178,8 @@ export function LldpLinksPage() {
if (!jobId) return;
setItemDetail(null);
setItemPage(1);
setItemStatus("");
setItemKeyword("");
setExpandedJobId(jobId);
setSearchParams(
(prev) => {
@ -168,15 +192,33 @@ export function LldpLinksPage() {
}, [searchParams, setSearchParams]);
const jobsQuery = useQuery({
queryKey: queryKeys.lldpCollectJobs(jobPage),
queryFn: () => fetchLldpCollectJobs({ page: jobPage, pageSize: 10 }),
queryKey: queryKeys.lldpCollectJobs(jobPage, jobPageSize, jobStatus, debouncedJobKeyword),
queryFn: () =>
fetchLldpCollectJobs({
page: jobPage,
pageSize: jobPageSize,
status: jobStatus,
keyword: debouncedJobKeyword,
}),
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 }),
queryKey: queryKeys.lldpCollectJob(
expandedJobId,
itemPage,
itemPageSize,
itemStatus,
debouncedItemKeyword,
),
queryFn: () =>
fetchLldpCollectJob(expandedJobId, {
page: itemPage,
pageSize: itemPageSize,
status: itemStatus,
keyword: debouncedItemKeyword,
}),
enabled: Boolean(expandedJobId),
staleTime: 800,
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
@ -191,13 +233,20 @@ export function LldpLinksPage() {
});
const edgesQuery = useQuery({
queryKey: queryKeys.fabricEdges(edgeStatus, edgeKeyword, edgePage),
queryKey: queryKeys.fabricEdges(
edgeStatus,
debouncedEdgeKeyword,
edgePage,
edgePageSize,
edgeSource,
),
queryFn: () =>
fetchFabricEdges({
status: edgeStatus === "all" ? "" : edgeStatus,
keyword: edgeKeyword,
keyword: debouncedEdgeKeyword,
source: edgeSource || undefined,
page: edgePage,
pageSize: EDGE_PAGE_SIZE,
pageSize: edgePageSize,
}),
staleTime: 2000,
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
@ -302,14 +351,192 @@ export function LldpLinksPage() {
const last = dash?.last_job;
const jobs = jobsQuery.data?.items ?? [];
const jobTotal = Number(jobsQuery.data?.total || 0);
const jobPages = pageCount(jobTotal, 10);
const jobPages = pageCount(jobTotal, jobPageSize);
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 itemPages = pageCount(itemTotal, itemPageSize);
const edgeItems = edgesQuery.data?.items ?? [];
const edgeTotal = Number(edgesQuery.data?.total || 0);
const edgePages = pageCount(edgeTotal, EDGE_PAGE_SIZE);
const edgePages = pageCount(edgeTotal, edgePageSize);
const exportEdgesCsv = async () => {
setExportingEdges(true);
try {
const rows = await fetchAllPages<FabricEdge>({
pageSize: 200,
maxRows: 2000,
fetchPage: (p, ps) =>
fetchFabricEdges({
status: edgeStatus === "all" ? "" : edgeStatus,
keyword: debouncedEdgeKeyword,
source: edgeSource || undefined,
page: p,
pageSize: ps,
}),
});
downloadCsv(
`${t("lldpLinks.exportEdgesName")}-${new Date().toISOString().slice(0, 10)}.csv`,
rows,
[
{
key: "status",
header: t("lldpLinks.col.status"),
value: (e) => lldpEdgeStatusLabel(t, e.status === "stale" ? "missing" : e.status),
},
{
key: "a",
header: t("lldpLinks.col.aSide"),
value: (e) => e.a_name || e.a_ip || e.a_node_id,
},
{ key: "a_port", header: t("lldpLinks.col.aPort") },
{
key: "b",
header: t("lldpLinks.col.bSide"),
value: (e) => e.b_name || e.b_ip || e.b_node_id,
},
{ key: "b_port", header: t("lldpLinks.col.bPort") },
{
key: "source",
header: t("lldpLinks.col.source"),
value: (e) => lldpEdgeSourceLabel(t, e.source),
},
{
key: "last_seen_at",
header: t("lldpLinks.col.lastSeen"),
value: (e) => (e.last_seen_at ? formatSystemTime(e.last_seen_at) : ""),
},
{ key: "id", header: "ID" },
],
);
if (rows.length < edgeTotal) {
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(edgeTotal) }));
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExportingEdges(false);
}
};
const exportJobsCsv = async () => {
setExportingJobs(true);
try {
const rows = await fetchAllPages<LldpCollectJobSummary>({
pageSize: 100,
maxRows: 2000,
fetchPage: (p, ps) =>
fetchLldpCollectJobs({
page: p,
pageSize: ps,
status: jobStatus,
keyword: debouncedJobKeyword,
}),
});
downloadCsv(
`${t("lldpLinks.exportJobsName")}-${new Date().toISOString().slice(0, 10)}.csv`,
rows,
[
{ key: "id", header: "ID" },
{
key: "trigger_mode",
header: t("lldpLinks.col.trigger"),
value: (j) => lldpTriggerLabel(t, j.trigger_mode),
},
{
key: "scope",
header: t("lldpLinks.col.scope"),
value: (j) => lldpScopeLabel(t, j.scope),
},
{
key: "status",
header: t("lldpLinks.col.status"),
value: (j) => lldpJobStatusLabel(t, j.status),
},
{
key: "progress",
header: t("lldpLinks.col.progress"),
value: (j) => `${j.done}/${j.total}`,
},
{
key: "edges",
header: t("lldpLinks.col.edges"),
value: (j) => `+${j.edges_added}/~${j.edges_updated}`,
},
{
key: "started_at",
header: t("lldpLinks.col.started"),
value: (j) => (j.started_at ? formatSystemTime(j.started_at) : ""),
},
{
key: "ended_at",
header: t("lldpLinks.col.ended"),
value: (j) => (j.ended_at ? formatSystemTime(j.ended_at) : ""),
},
],
);
if (rows.length < jobTotal) {
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(jobTotal) }));
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExportingJobs(false);
}
};
const exportItemsCsv = async () => {
if (!expandedJobId) return;
setExportingItems(true);
try {
const rows = await fetchAllPages<TopologyDiscoverJobItem>({
pageSize: 100,
maxRows: 2000,
fetchPage: async (p, ps) => {
const job = await fetchLldpCollectJob(expandedJobId, {
page: p,
pageSize: ps,
status: itemStatus,
keyword: debouncedItemKeyword,
});
return { total: job.items_total, items: (job.items || []) as TopologyDiscoverJobItem[] };
},
});
downloadCsv(
`${t("lldpLinks.exportItemsName")}-${expandedJobId.slice(0, 8)}.csv`,
rows,
[
{ key: "ne_name", header: t("lldpLinks.col.name") },
{ key: "ne_ip", header: "IP" },
{
key: "result",
header: t("lldpLinks.col.status"),
value: (it) => lldpItemResultLabel(t, it),
},
{ key: "neighbors", header: t("lldpLinks.col.neighbors") },
{
key: "edges",
header: t("lldpLinks.col.edges"),
value: (it) => `+${it.edges_added}/~${it.edges_updated}`,
},
{ key: "error", header: t("lldpLinks.col.error") },
],
);
if (rows.length < itemTotal) {
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(itemTotal) }));
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExportingItems(false);
}
};
const toggleTarget = (row: CliTargetItem) => {
const source = row.source === "ume" ? "ume" : "managed";
@ -576,6 +803,17 @@ export function LldpLinksPage() {
<option value="active">{t("lldpLinks.edgeStatusActive")}</option>
<option value="missing">{t("lldpLinks.edgeStatusMissing")}</option>
</select>
<select
value={edgeSource}
onChange={(e) => {
setEdgeSource(e.target.value);
setEdgePage(1);
}}
>
<option value="">{t("lldpLinks.edgeSourceAll")}</option>
<option value="lldp">{t("lldpLinks.edgeSource.lldp")}</option>
<option value="manual">{t("lldpLinks.edgeSource.manual")}</option>
</select>
<input
value={edgeKeyword}
placeholder={t("lldpLinks.edgeKeywordPh")}
@ -584,6 +822,13 @@ export function LldpLinksPage() {
setEdgePage(1);
}}
/>
<button
type="button"
disabled={exportingEdges || edgeTotal === 0}
onClick={() => void exportEdgesCsv()}
>
{exportingEdges ? t("common.exporting") : t("common.exportCsv")}
</button>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
@ -643,27 +888,54 @@ export function LldpLinksPage() {
</tbody>
</table>
</div>
<div className="pager">
<button type="button" disabled={edgePage <= 1} onClick={() => setEdgePage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<span className="muted">
{t("common.pagerMeta", {
total: String(edgeTotal),
page: String(edgePage),
pages: String(edgePages),
})}
</span>
<button
type="button"
disabled={edgePage >= edgePages}
onClick={() => setEdgePage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
<ListPager
page={edgePage}
pages={edgePages}
total={edgeTotal}
pageSize={edgePageSize}
pageSizeOptions={[20, 50, 100, 200]}
onPageChange={setEdgePage}
onPageSizeChange={(size) => {
setEdgePageSize(size);
setEdgePage(1);
}}
disabled={edgesQuery.isLoading}
/>
<h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<select
value={jobStatus}
onChange={(e) => {
setJobStatus(e.target.value);
setJobPage(1);
}}
>
<option value="">{t("lldpLinks.statusAll")}</option>
<option value="running">{t("lldpLinks.jobStatus.running")}</option>
<option value="pending">{t("lldpLinks.jobStatus.pending")}</option>
<option value="paused">{t("lldpLinks.jobStatus.paused")}</option>
<option value="done">{t("lldpLinks.jobStatus.done")}</option>
<option value="failed">{t("lldpLinks.jobStatus.failed")}</option>
<option value="cancelled">{t("lldpLinks.jobStatus.cancelled")}</option>
<option value="stopped">{t("lldpLinks.jobStatus.stopped")}</option>
</select>
<input
value={jobKeyword}
placeholder={t("lldpLinks.jobKeywordPh")}
onChange={(e) => {
setJobKeyword(e.target.value);
setJobPage(1);
}}
/>
<button
type="button"
disabled={exportingJobs || jobTotal === 0}
onClick={() => void exportJobsCsv()}
>
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
</button>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
@ -691,6 +963,8 @@ export function LldpLinksPage() {
className="btn btn--sm btn--ghost"
onClick={() => {
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
setExpandedJobId(job.id);
setItemPage(1);
}}
@ -774,21 +1048,19 @@ export function LldpLinksPage() {
</tbody>
</table>
</div>
<div className="pager">
<button type="button" disabled={jobPage <= 1} onClick={() => setJobPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<span className="muted">
{t("common.pagerMeta", {
total: String(jobTotal),
page: String(jobPage),
pages: String(jobPages),
})}
</span>
<button type="button" disabled={jobPage >= jobPages} onClick={() => setJobPage((p) => p + 1)}>
{t("common.nextPage")}
</button>
</div>
<ListPager
page={jobPage}
pages={jobPages}
total={jobTotal}
pageSize={jobPageSize}
pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setJobPage}
onPageSizeChange={(size) => {
setJobPageSize(size);
setJobPage(1);
}}
disabled={jobsQuery.isLoading}
/>
{expandedJobId ? (
<div
@ -823,11 +1095,21 @@ export function LldpLinksPage() {
</p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button
type="button"
disabled={exportingItems || itemTotal === 0}
onClick={() => void exportItemsCsv()}
>
{exportingItems ? t("common.exporting") : t("common.exportCsv")}
</button>
<button
type="button"
onClick={() => {
setExpandedJobId("");
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
setItemPage(1);
}}
>
{t("networkConfigs.close")}
@ -835,6 +1117,29 @@ export function LldpLinksPage() {
</div>
</div>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<select
value={itemStatus}
onChange={(e) => {
setItemStatus(e.target.value);
setItemPage(1);
}}
>
<option value="">{t("lldpLinks.itemResultAll")}</option>
<option value="ok">{t("lldpLinks.itemResult.ok")}</option>
<option value="warn">{t("lldpLinks.itemResult.warn")}</option>
<option value="fail">{t("lldpLinks.itemResult.fail")}</option>
</select>
<input
value={itemKeyword}
placeholder={t("lldpLinks.itemKeywordPh")}
onChange={(e) => {
setItemKeyword(e.target.value);
setItemPage(1);
}}
/>
</div>
{jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{jobDetailQuery.data?.error ? (
<p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
@ -897,25 +1202,19 @@ export function LldpLinksPage() {
</div>
<div className="ops-detail-modal__foot">
<span className="muted">
{t("common.pagerMeta", {
total: String(itemTotal),
page: String(itemPage),
pages: String(itemPages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={itemPage <= 1} onClick={() => setItemPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<button
type="button"
disabled={itemPage >= itemPages}
onClick={() => setItemPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
<ListPager
page={itemPage}
pages={itemPages}
total={itemTotal}
pageSize={itemPageSize}
pageSizeOptions={[20, 50, 100]}
onPageChange={setItemPage}
onPageSizeChange={(size) => {
setItemPageSize(size);
setItemPage(1);
}}
disabled={jobDetailQuery.isLoading}
/>
</div>
</div>
</div>

View file

@ -1,8 +1,13 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchUmeCurrentAlarms } from "../../services/api";
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 { fetchUmeCurrentAlarms, formatErr } from "../../services/api";
import type { UmeAlarmItem } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
@ -19,19 +24,24 @@ function severityClass(sev: string | null | undefined): string {
/** Current UME alarms query view for Network Management. */
export function NetworkAlarmsPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [curSeverity, setCurSeverity] = useState("");
const [curCleared, setCurCleared] = useState("");
const [curHostName, setCurHostName] = useState("");
const [curKeyword, setCurKeyword] = useState("");
const [curPage, setCurPage] = useState(1);
const [curPageSize, setCurPageSize] = useState(50);
const [exporting, setExporting] = useState(false);
const debouncedKeyword = useDebouncedValue(curKeyword, 300);
const debouncedHost = useDebouncedValue(curHostName, 300);
const currentQuery = useQuery({
queryKey: queryKeys.umeCurrentAlarms(
curSeverity,
curCleared,
curHostName,
curKeyword,
debouncedHost,
debouncedKeyword,
curPage,
curPageSize,
),
@ -39,8 +49,8 @@ export function NetworkAlarmsPage() {
fetchUmeCurrentAlarms({
severity: curSeverity,
isCleared: curCleared,
hostName: curHostName,
keyword: curKeyword,
hostName: debouncedHost,
keyword: debouncedKeyword,
page: curPage,
pageSize: curPageSize,
}),
@ -50,13 +60,57 @@ export function NetworkAlarmsPage() {
const items = currentQuery.data?.items || [];
const curTotal = Number(currentQuery.data?.total || 0);
const curPages = pageCount(curTotal, curPageSize);
const perPage = (n: number) => t("common.perPage", { n: String(n) });
const hasFilters = Boolean(curKeyword.trim() || curHostName.trim() || curSeverity || curCleared);
const exportCsv = async () => {
setExporting(true);
try {
const rows = await fetchAllPages<UmeAlarmItem>({
pageSize: 200,
maxRows: 2000,
fetchPage: (page, pageSize) =>
fetchUmeCurrentAlarms({
severity: curSeverity,
isCleared: curCleared,
hostName: debouncedHost,
keyword: debouncedKeyword,
page,
pageSize,
}),
});
downloadCsv(`ume-alarms-${new Date().toISOString().slice(0, 10)}.csv`, rows, [
{ key: "time_created", header: t("ume.alarms.col.time"), value: (r) => formatSystemTime(r.time_created) },
{ key: "perceived_severity", header: t("ume.alarms.col.severity") },
{ key: "ne_id", header: t("ume.alarms.col.neId") },
{ key: "host_name", header: t("ume.alarms.col.hostName") },
{ key: "ne_type", header: t("ume.alarms.col.neType") },
{ key: "native_probable_cause", header: t("ume.alarms.col.cause") },
{ key: "is_cleared", header: "is_cleared" },
{ key: "alarm_key", header: "alarm_key" },
]);
if (rows.length < curTotal) {
showOk(
t("common.exportTruncated", { count: String(rows.length), total: String(curTotal) }),
);
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExporting(false);
}
};
return (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2>
<div className="btn-row">
<button type="button" disabled={exporting || curTotal === 0} onClick={() => void exportCsv()}>
{exporting ? t("common.exporting") : t("common.exportCsv")}
</button>
</div>
</div>
<div className="pt-list">
@ -99,8 +153,8 @@ export function NetworkAlarmsPage() {
}}
>
<option value="">{t("ume.alarms.clearedAll")}</option>
<option value="true">true</option>
<option value="false">false</option>
<option value="false">{t("ume.alarms.clearedNo")}</option>
<option value="true">{t("ume.alarms.clearedYes")}</option>
</select>
<button
type="button"
@ -130,12 +184,12 @@ export function NetworkAlarmsPage() {
<table className="data-table pt-list-table">
<thead>
<tr>
<th>time_created</th>
<th>severity</th>
<th>ne_id</th>
<th>host_name</th>
<th>ne_type</th>
<th>cause</th>
<th>{t("ume.alarms.col.time")}</th>
<th>{t("ume.alarms.col.severity")}</th>
<th>{t("ume.alarms.col.neId")}</th>
<th>{t("ume.alarms.col.hostName")}</th>
<th>{t("ume.alarms.col.neType")}</th>
<th>{t("ume.alarms.col.cause")}</th>
</tr>
</thead>
<tbody>
@ -177,46 +231,19 @@ export function NetworkAlarmsPage() {
</div>
)}
<div className="pager pt-list-pager">
<div className="pager__meta muted">
{t("common.pagerMeta", {
total: String(curTotal),
page: String(curPage),
pages: String(curPages),
})}
</div>
<div className="pager__controls btn-row">
<button
className="pager__btn"
type="button"
onClick={() => setCurPage(Math.max(1, curPage - 1))}
disabled={curPage <= 1}
>
{t("common.prevPage")}
</button>
<button
className="pager__btn"
type="button"
onClick={() => setCurPage(curPage + 1)}
disabled={curPage >= curPages}
>
{t("common.nextPage")}
</button>
<select
className="pager__size"
value={String(curPageSize)}
onChange={(e) => {
setCurPageSize(Number(e.target.value) || 50);
setCurPage(1);
}}
>
<option value="50">{perPage(50)}</option>
<option value="100">{perPage(100)}</option>
<option value="200">{perPage(200)}</option>
<option value="500">{perPage(500)}</option>
</select>
</div>
</div>
<ListPager
page={curPage}
pages={curPages}
total={curTotal}
pageSize={curPageSize}
pageSizeOptions={[50, 100, 200, 500]}
onPageChange={setCurPage}
onPageSizeChange={(size) => {
setCurPageSize(size);
setCurPage(1);
}}
disabled={currentQuery.isLoading}
/>
</div>
</section>
);

View file

@ -1,15 +1,21 @@
import { useState } from "react";
import { useEffect, useState } from "react";
import { useQuery } 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 {
downloadNeConfigSnapshot,
fetchNeConfigSnapshotDetail,
fetchNeConfigSnapshots,
formatErr,
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { openNewModuleWindow } from "../../utils/moduleWindows";
import type { NeConfigSnapshotMeta } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { openNewModuleWindow } from "../../utils/moduleWindows";
import { formatSystemTime } from "../../utils/time";
function fmtBytes(n: number): string {
@ -22,18 +28,45 @@ function fmtBytes(n: number): string {
export function NetworkConfigsPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const [page, setPage] = useState(1);
const [keyword, setKeyword] = useState("");
const [pageSize, setPageSize] = useState(50);
const [keyword, setKeyword] = useState(() => String(searchParams.get("q") || ""));
const [source, setSource] = useState("");
const [vendor, setVendor] = useState("");
const [selected, setSelected] = useState<{ source: string; id: string } | null>(null);
const [tab, setTab] = useState<"primary" | "alt">("primary");
const [exporting, setExporting] = useState("");
const [exportingList, setExportingList] = useState(false);
const debouncedKeyword = useDebouncedValue(keyword, 300);
const debouncedVendor = useDebouncedValue(vendor, 300);
useEffect(() => {
const q = String(searchParams.get("q") || "").trim();
if (!q) return;
setKeyword(q);
setPage(1);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (next.get("q") === q) next.delete("q");
return next;
},
{ replace: true },
);
}, [searchParams, setSearchParams]);
const listQuery = useQuery({
queryKey: queryKeys.networkConfigs(page, keyword, source, vendor),
queryKey: queryKeys.networkConfigs(page, debouncedKeyword, source, debouncedVendor, pageSize),
queryFn: () =>
fetchNeConfigSnapshots({ page, pageSize: 20, keyword, source, vendor }),
fetchNeConfigSnapshots({
page,
pageSize,
keyword: debouncedKeyword,
source,
vendor: debouncedVendor,
}),
staleTime: 5000,
});
@ -46,7 +79,7 @@ export function NetworkConfigsPage() {
const items = listQuery.data?.items ?? [];
const total = Number(listQuery.data?.total || 0);
const pages = pageCount(total, 20);
const pages = pageCount(total, pageSize);
const detail = detailQuery.data;
const showAlt = Boolean(detail?.has_alt);
const hasFilters = Boolean(keyword || source || vendor);
@ -62,16 +95,69 @@ export function NetworkConfigsPage() {
await downloadNeConfigSnapshot(src, id, field);
showOk(t("networkConfigs.exportOk"));
} catch (err) {
showError(String(err));
showError(formatErr(err));
} finally {
setExporting("");
}
};
const exportListCsv = async () => {
setExportingList(true);
try {
const rows = await fetchAllPages<NeConfigSnapshotMeta>({
pageSize: 100,
maxRows: 2000,
fetchPage: (p, ps) =>
fetchNeConfigSnapshots({
page: p,
pageSize: ps,
keyword: debouncedKeyword,
source,
vendor: debouncedVendor,
}),
});
downloadCsv(
`${t("networkConfigs.exportListName")}-${new Date().toISOString().slice(0, 10)}.csv`,
rows,
[
{ key: "ne_name", header: t("networkConfigs.col.name"), value: (r) => r.ne_name || r.target_id },
{ key: "ne_ip", header: "IP" },
{ key: "vendor", header: t("networkConfigs.col.vendor") },
{ key: "source", header: t("networkConfigs.col.source") },
{ key: "plain_size", header: t("networkConfigs.col.size"), value: (r) => fmtBytes(r.plain_size) },
{
key: "collected_at",
header: t("networkConfigs.col.collected"),
value: (r) => (r.collected_at ? formatSystemTime(r.collected_at) : ""),
},
{ key: "target_id", header: "ID" },
],
);
if (rows.length < total) {
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(total) }));
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExportingList(false);
}
};
return (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("networkConfigs.title")}</h2>
<div className="btn-row">
<button
type="button"
disabled={exportingList || total === 0}
onClick={() => void exportListCsv()}
>
{exportingList ? t("common.exporting") : t("common.exportCsv")}
</button>
</div>
</div>
<div className="pt-list">
@ -118,6 +204,7 @@ export function NetworkConfigsPage() {
</div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{listQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!items.length && !listQuery.isLoading ? (
<div className="pt-list-empty">
@ -199,23 +286,19 @@ export function NetworkConfigsPage() {
</div>
)}
<div className="pager pt-list-pager">
<span className="muted">
{t("common.pagerMeta", {
total: String(total),
page: String(page),
pages: String(pages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}>
{t("common.nextPage")}
</button>
</div>
</div>
<ListPager
page={page}
pages={pages}
total={total}
pageSize={pageSize}
pageSizeOptions={[20, 50, 100]}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
disabled={listQuery.isLoading}
/>
</div>
{selected ? (

View file

@ -1,11 +1,15 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchCliTargets } from "../../services/api";
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 { fetchCliTargets, formatErr } from "../../services/api";
import type { CliTargetItem } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
const PAGE_SIZE = 50;
import { openNewModuleWindow } from "../../utils/moduleWindows";
function connectStatusClass(status: string | null | undefined): string {
const s = String(status || "").trim().toLowerCase();
@ -17,28 +21,80 @@ function connectStatusClass(status: string | null | undefined): string {
return "pt-list-status--other";
}
/** Read-only inventory of managed + UME NEs for Network Management. */
function openWebcrt(row: CliTargetItem) {
const path =
row.source === "ume"
? `/webcrt?ne_id=${encodeURIComponent(row.id)}&source=ume`
: `/webcrt?ne_id=${encodeURIComponent(row.id)}`;
openNewModuleWindow({ moduleId: "webcrt", path });
}
/** Inventory of managed + UME NEs for Network Management. */
export function NetworkDevicesPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [keyword, setKeyword] = useState("");
const [source, setSource] = useState<"all" | "managed" | "ume">("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [exporting, setExporting] = useState(false);
const debouncedKeyword = useDebouncedValue(keyword, 300);
const listQuery = useQuery({
queryKey: queryKeys.cliTargets(`${source}:${keyword}`, page, PAGE_SIZE),
queryFn: () => fetchCliTargets({ source, keyword, page, pageSize: PAGE_SIZE }),
queryKey: queryKeys.cliTargets(`${source}:${debouncedKeyword}`, page, pageSize),
queryFn: () => fetchCliTargets({ source, keyword: debouncedKeyword, page, pageSize }),
staleTime: 5000,
});
const items = listQuery.data?.items ?? [];
const total = Number(listQuery.data?.total || 0);
const pages = pageCount(total, PAGE_SIZE);
const pages = pageCount(total, pageSize);
const hasFilters = Boolean(keyword.trim() || source !== "all");
const exportCsv = async () => {
setExporting(true);
try {
const rows = await fetchAllPages<CliTargetItem>({
pageSize: 200,
maxRows: 2000,
fetchPage: (p, ps) =>
fetchCliTargets({ source, keyword: debouncedKeyword, page: p, pageSize: ps }),
});
downloadCsv(`${t("networkDevices.exportName")}-${new Date().toISOString().slice(0, 10)}.csv`, rows, [
{ key: "source", header: t("networkDevices.col.source") },
{ key: "name", header: t("networkDevices.col.name"), value: (r) => r.name || r.id },
{ key: "ip_address", header: "IP" },
{ key: "vendor", header: t("networkDevices.col.vendor") },
{
key: "device_type",
header: t("networkDevices.col.deviceType"),
value: (r) => r.device_type || r.ne_type || "",
},
{ key: "connect_status", header: t("networkDevices.col.connect") },
{ key: "id", header: "ID" },
]);
if (rows.length < total) {
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(total) }));
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExporting(false);
}
};
return (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("networkDevices.title")}</h2>
<div className="btn-row">
<button type="button" disabled={exporting || total === 0} onClick={() => void exportCsv()}>
{exporting ? t("common.exporting") : t("common.exportCsv")}
</button>
</div>
</div>
<div className="pt-list">
@ -76,6 +132,7 @@ export function NetworkDevicesPage() {
</div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{listQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!items.length && !listQuery.isLoading ? (
<div className="pt-list-empty">
@ -92,6 +149,7 @@ export function NetworkDevicesPage() {
<th>{t("networkDevices.col.vendor")}</th>
<th>{t("networkDevices.col.deviceType")}</th>
<th>{t("networkDevices.col.connect")}</th>
<th>{t("networkDevices.col.actions")}</th>
</tr>
</thead>
<tbody>
@ -107,6 +165,24 @@ export function NetworkDevicesPage() {
{row.connect_status || "—"}
</span>
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<button type="button" onClick={() => openWebcrt(row)}>
WebCRT
</button>
<button
type="button"
onClick={() =>
openNewModuleWindow({
moduleId: "network",
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
})
}
>
{t("network.nav.configs")}
</button>
</div>
</td>
</tr>
))}
</tbody>
@ -114,23 +190,19 @@ export function NetworkDevicesPage() {
</div>
)}
<div className="pager pt-list-pager">
<span className="muted">
{t("common.pagerMeta", {
total: String(total),
page: String(page),
pages: String(pages),
})}
</span>
<div className="btn-row">
<button type="button" disabled={page <= 1} onClick={() => setPage((p) => p - 1)}>
{t("common.prevPage")}
</button>
<button type="button" disabled={page >= pages} onClick={() => setPage((p) => p + 1)}>
{t("common.nextPage")}
</button>
</div>
</div>
<ListPager
page={page}
pages={pages}
total={total}
pageSize={pageSize}
pageSizeOptions={[20, 50, 100, 200]}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
disabled={listQuery.isLoading}
/>
</div>
</section>
);

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager";
import {
createPortTrafficDevice,
deletePortTrafficDevice,
@ -10,6 +11,7 @@ import {
fetchPortTrafficDevices,
fetchPortTrafficEvents,
fetchPortTrafficTargets,
formatErr,
pausePortTrafficDevice,
putPortTrafficInterfaces,
rebindPortTrafficDevice,
@ -19,13 +21,16 @@ import {
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import type {
CliTargetItem,
PortTrafficDevice,
PortTrafficDiscoverPort,
PortTrafficEvent,
PortTrafficIfaceIn,
} from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
@ -34,10 +39,11 @@ const TARGET_PAGE_SIZE = 20;
type ViewMode = "list" | "wizard" | "edit";
function statusTone(status: string): "running" | "paused" | "stopped" | "other" {
function statusTone(status: string): "running" | "paused" | "stopped" | "draft" | "other" {
if (status === "running") return "running";
if (status === "paused") return "paused";
if (status === "stopped") return "stopped";
if (status === "draft") return "draft";
return "other";
}
@ -122,6 +128,11 @@ export function PortTrafficPage() {
const [view, setView] = useState<ViewMode>("list");
const [listPage, setListPage] = useState(1);
const [listPageSize, setListPageSize] = useState(50);
const [listKeyword, setListKeyword] = useState("");
const [listStatus, setListStatus] = useState("");
const [exportingDevices, setExportingDevices] = useState(false);
const debouncedListKeyword = useDebouncedValue(listKeyword, 300);
// Wizard / edit
const [editDeviceId, setEditDeviceId] = useState("");
@ -151,6 +162,7 @@ export function PortTrafficPage() {
const [wizardStep, setWizardStep] = useState<1 | 2 | 3>(1);
const [logDeviceId, setLogDeviceId] = useState("");
const [logDeviceLabel, setLogDeviceLabel] = useState("");
const [logKeyword, setLogKeyword] = useState("");
const [rebindDevice, setRebindDevice] = useState<PortTrafficDevice | null>(null);
const [rebindKeyword, setRebindKeyword] = useState("");
const [rebindPage, setRebindPage] = useState(1);
@ -169,8 +181,14 @@ export function PortTrafficPage() {
});
const devicesQuery = useQuery({
queryKey: queryKeys.portTrafficDevices(listPage),
queryFn: () => fetchPortTrafficDevices({ page: listPage, pageSize: 10 }),
queryKey: queryKeys.portTrafficDevices(listPage, listPageSize, listStatus, debouncedListKeyword),
queryFn: () =>
fetchPortTrafficDevices({
page: listPage,
pageSize: listPageSize,
status: listStatus || undefined,
keyword: debouncedListKeyword || undefined,
}),
staleTime: 5000,
refetchInterval: (q) => {
const items = q.state.data?.items || [];
@ -570,17 +588,109 @@ export function PortTrafficPage() {
const dash = dashQuery.data;
const devices = devicesQuery.data?.items || [];
const pages = pageCount(devicesQuery.data?.total || 0, 10);
const listTotal = Number(devicesQuery.data?.total || 0);
const pages = pageCount(listTotal, listPageSize);
const editDevice = editDeviceSnap;
const hasListFilters = Boolean(listKeyword.trim() || listStatus);
const logEvents = logQuery.data?.items || [];
const logKw = logKeyword.trim().toLowerCase();
const filteredLogEvents = !logKw
? logEvents
: logEvents.filter((ev) => {
const msg = formatPortTrafficLogMessage(ev.message, t).toLowerCase();
const hay = `${ev.level || ""} ${ev.ifname || ""} ${ev.message || ""} ${msg}`.toLowerCase();
return hay.includes(logKw);
});
const statusText = (status: string) => {
const tone = statusTone(status);
if (tone === "running") return t("portTraffic.statusRunning");
if (tone === "paused") return t("portTraffic.statusPaused");
if (tone === "stopped") return t("portTraffic.statusStopped");
if (tone === "draft") return t("portTraffic.statusDraft");
return status;
};
const closeLog = () => {
setLogDeviceId("");
setLogDeviceLabel("");
setLogKeyword("");
};
const exportDevicesCsv = async () => {
setExportingDevices(true);
try {
const rows = await fetchAllPages<PortTrafficDevice>({
pageSize: 100,
maxRows: 2000,
fetchPage: (p, ps) =>
fetchPortTrafficDevices({
page: p,
pageSize: ps,
status: listStatus || undefined,
keyword: debouncedListKeyword || undefined,
}),
});
downloadCsv(
`${t("portTraffic.exportDevicesName")}-${new Date().toISOString().slice(0, 10)}.csv`,
rows,
[
{ key: "ne_name", header: t("portTraffic.col.device"), value: (r) => deviceLabel(r) },
{ key: "ne_ip", header: "IP" },
{ key: "ne_id", header: "NE ID" },
{ key: "vendor", header: t("portTraffic.col.vendor") },
{ key: "status", header: t("portTraffic.col.status"), value: (r) => statusText(r.status) },
{ key: "target_count", header: t("portTraffic.col.ports") },
{ key: "active_target_count", header: "Active ports" },
{ key: "interval_sec", header: t("portTraffic.col.interval") },
{
key: "last_collect_ended_at",
header: t("portTraffic.col.last"),
value: (r) => formatSystemTime(r.last_collect_ended_at) || "",
},
{ key: "note", header: t("portTraffic.note") },
{ key: "last_error", header: t("portTraffic.logMessage") },
{ key: "id", header: "ID" },
],
);
if (rows.length < listTotal) {
showOk(t("common.exportTruncated", { count: String(rows.length), total: String(listTotal) }));
} else {
showOk(t("common.exportOk", { count: String(rows.length) }));
}
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
} finally {
setExportingDevices(false);
}
};
const exportEventsCsv = () => {
try {
downloadCsv(
`${t("portTraffic.exportEventsName")}-${new Date().toISOString().slice(0, 10)}.csv`,
filteredLogEvents,
[
{
key: "created_at",
header: t("portTraffic.logTime"),
value: (r: PortTrafficEvent) => formatSystemTime(r.created_at) || "",
},
{ key: "level", header: t("portTraffic.logLevel") },
{ key: "ifname", header: t("portTraffic.col.ifname") },
{
key: "message",
header: t("portTraffic.logMessage"),
value: (r: PortTrafficEvent) => formatPortTrafficLogMessage(r.message, t),
},
],
);
showOk(t("common.exportOk", { count: String(filteredLogEvents.length) }));
} catch (err) {
showError(t("common.exportFailed") + ": " + formatErr(err));
}
};
return (
<section className="panel">
<div className="panel__toolbar">
@ -601,6 +711,13 @@ export function PortTrafficPage() {
<button type="button" onClick={() => navigate("/network/tasks/port-traffic/wall")}>
{t("portTraffic.wall")}
</button>
<button
type="button"
disabled={exportingDevices || listTotal === 0}
onClick={() => void exportDevicesCsv()}
>
{exportingDevices ? t("common.exporting") : t("common.exportCsv")}
</button>
<button type="button" className="btn-primary" onClick={openWizard}>
{t("portTraffic.create")}
</button>
@ -634,157 +751,189 @@ export function PortTrafficPage() {
</div>
</div>
{!devices.length ? (
<div className="filter-inline">
<input
value={listKeyword}
placeholder={t("portTraffic.keywordPh")}
onChange={(e) => {
setListKeyword(e.target.value);
setListPage(1);
}}
/>
<select
value={listStatus}
onChange={(e) => {
setListStatus(e.target.value);
setListPage(1);
}}
>
<option value="">{t("portTraffic.allStatus")}</option>
<option value="running">{t("portTraffic.statusRunning")}</option>
<option value="paused">{t("portTraffic.statusPaused")}</option>
<option value="stopped">{t("portTraffic.statusStopped")}</option>
<option value="draft">{t("portTraffic.statusDraft")}</option>
</select>
<button
type="button"
disabled={!hasListFilters}
onClick={() => {
setListKeyword("");
setListStatus("");
setListPage(1);
}}
>
{t("common.clearFilters")}
</button>
</div>
{devicesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{devicesQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!devices.length && !devicesQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("portTraffic.empty")}</p>
<button type="button" className="btn-primary" onClick={openWizard}>
{t("portTraffic.create")}
</button>
{!hasListFilters ? (
<button type="button" className="btn-primary" onClick={openWizard}>
{t("portTraffic.create")}
</button>
) : null}
</div>
) : (
<>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("portTraffic.col.device")}</th>
<th>{t("portTraffic.col.status")}</th>
<th>{t("portTraffic.col.ports")}</th>
<th>{t("portTraffic.col.interval")}</th>
<th>{t("portTraffic.col.last")}</th>
<th>{t("portTraffic.col.actions")}</th>
</tr>
</thead>
<tbody>
{devices.map((row) => {
const tone = statusTone(row.status);
return (
<tr key={row.id}>
<td>
<div className="pt-list-task-name">{deviceLabel(row)}</div>
<div className="muted" style={{ fontSize: 12 }}>
{row.ne_ip || row.ne_id}
{row.vendor ? ` · ${row.vendor}` : ""}
{row.note ? ` · ${row.note}` : ""}
</div>
</td>
<td>
<span className={`pt-list-status pt-list-status--${tone}`}>
{statusText(row.status)}
) : devices.length ? (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("portTraffic.col.device")}</th>
<th>{t("portTraffic.col.status")}</th>
<th>{t("portTraffic.col.ports")}</th>
<th>{t("portTraffic.col.interval")}</th>
<th>{t("portTraffic.col.last")}</th>
<th>{t("portTraffic.col.actions")}</th>
</tr>
</thead>
<tbody>
{devices.map((row) => {
const tone = statusTone(row.status);
return (
<tr key={row.id}>
<td>
<div className="pt-list-task-name">{deviceLabel(row)}</div>
<div className="muted" style={{ fontSize: 12 }}>
{row.ne_ip || row.ne_id}
{row.vendor ? ` · ${row.vendor}` : ""}
{row.note ? ` · ${row.note}` : ""}
</div>
</td>
<td>
<span className={`pt-list-status pt-list-status--${tone}`}>
{statusText(row.status)}
</span>
{row.collect_running ? (
<span className="pt-list-status pt-list-status--collect">
{t("portTraffic.collecting")}
</span>
{row.collect_running ? (
<span className="pt-list-status pt-list-status--collect">
{t("portTraffic.collecting")}
</span>
) : null}
</td>
<td className="pt-list-num">
{row.active_target_count}/{row.target_count}
</td>
<td className="pt-list-num">{row.interval_sec}s</td>
<td className="pt-list-time">
{formatSystemTime(row.last_collect_ended_at) || "—"}
</td>
<td>
<div className="pt-list-actions">
<button
type="button"
className="btn-primary"
onClick={() => openWallList()}
>
{t("portTraffic.wall")}
</button>
<button type="button" onClick={() => openEdit(row)}>
{t("portTraffic.edit")}
</button>
{needsNeRebind(row) ? (
<button
type="button"
title={t("portTraffic.rebindHint")}
onClick={() => openRebind(row)}
>
{t("portTraffic.rebind")}
</button>
) : null}
</td>
<td className="pt-list-num">
{row.active_target_count}/{row.target_count}
</td>
<td className="pt-list-num">{row.interval_sec}s</td>
<td className="pt-list-time">
{formatSystemTime(row.last_collect_ended_at) || "—"}
</td>
<td>
<div className="pt-list-actions">
{row.status !== "running" ? (
<button
type="button"
className="btn-primary"
onClick={() => openWallList()}
disabled={startMut.isPending}
onClick={() => startMut.mutate(row.id)}
>
{t("portTraffic.wall")}
{t("portTraffic.start")}
</button>
<button type="button" onClick={() => openEdit(row)}>
{t("portTraffic.edit")}
</button>
{needsNeRebind(row) ? (
<button
type="button"
title={t("portTraffic.rebindHint")}
onClick={() => openRebind(row)}
>
{t("portTraffic.rebind")}
</button>
) : null}
{row.status !== "running" ? (
<button
type="button"
disabled={startMut.isPending}
onClick={() => startMut.mutate(row.id)}
>
{t("portTraffic.start")}
</button>
) : (
<button
type="button"
disabled={pauseMut.isPending}
onClick={() => pauseMut.mutate(row.id)}
>
{t("portTraffic.pause")}
</button>
)}
{row.status !== "stopped" ? (
<button
type="button"
disabled={stopMut.isPending}
onClick={() => stopMut.mutate(row.id)}
>
{t("portTraffic.stop")}
</button>
) : null}
) : (
<button
type="button"
className={row.last_error ? "pt-list-log-btn--error" : undefined}
onClick={() => {
setLogDeviceId(row.id);
setLogDeviceLabel(deviceLabel(row));
}}
disabled={pauseMut.isPending}
onClick={() => pauseMut.mutate(row.id)}
>
{t("portTraffic.log")}
{t("portTraffic.pause")}
</button>
)}
{row.status !== "stopped" ? (
<button
type="button"
className="btn--danger"
disabled={deleteMut.isPending}
onClick={() => {
if (window.confirm(t("portTraffic.confirmDelete"))) {
deleteMut.mutate(row.id);
}
}}
disabled={stopMut.isPending}
onClick={() => stopMut.mutate(row.id)}
>
{t("portTraffic.delete")}
{t("portTraffic.stop")}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="pager pt-list-pager">
<button
type="button"
className="pager__btn"
disabled={listPage <= 1}
onClick={() => setListPage((p) => p - 1)}
>
‹
</button>
<span className="muted">
{listPage}/{Math.max(1, pages)}
</span>
<button
type="button"
className="pager__btn"
disabled={listPage >= pages}
onClick={() => setListPage((p) => p + 1)}
>
›
</button>
</div>
</>
)}
) : null}
<button
type="button"
className={row.last_error ? "pt-list-log-btn--error" : undefined}
onClick={() => {
setLogDeviceId(row.id);
setLogDeviceLabel(deviceLabel(row));
setLogKeyword("");
}}
>
{t("portTraffic.log")}
</button>
<button
type="button"
className="btn--danger"
disabled={deleteMut.isPending}
onClick={() => {
if (window.confirm(t("portTraffic.confirmDelete"))) {
deleteMut.mutate(row.id);
}
}}
>
{t("portTraffic.delete")}
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : null}
<ListPager
page={listPage}
pages={pages}
total={listTotal}
pageSize={listPageSize}
pageSizeOptions={[20, 50, 100]}
onPageChange={setListPage}
onPageSizeChange={(size) => {
setListPageSize(size);
setListPage(1);
}}
disabled={devicesQuery.isLoading}
/>
</div>
) : null}
@ -1179,14 +1328,7 @@ export function PortTrafficPage() {
) : null}
{logDeviceId ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => {
setLogDeviceId("");
setLogDeviceLabel("");
}}
>
<div className="modal-backdrop" role="presentation" onClick={closeLog}>
<div
className="modal modal--wide pt-log-modal"
role="dialog"
@ -1201,23 +1343,35 @@ export function PortTrafficPage() {
{t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
</p>
</div>
<button
type="button"
onClick={() => {
setLogDeviceId("");
setLogDeviceLabel("");
}}
>
{t("portTraffic.logClose")}
</button>
<div className="btn-row">
<button
type="button"
disabled={!filteredLogEvents.length}
onClick={exportEventsCsv}
>
{t("common.exportCsv")}
</button>
<button type="button" onClick={closeLog}>
{t("portTraffic.logClose")}
</button>
</div>
</div>
<div className="filter-inline" style={{ marginBottom: 10 }}>
<input
value={logKeyword}
placeholder={t("portTraffic.keywordPh")}
onChange={(e) => setLogKeyword(e.target.value)}
/>
</div>
{logQuery.isLoading ? (
<p className="muted">…</p>
) : !(logQuery.data?.items || []).length ? (
) : !logEvents.length ? (
<p className="muted">{t("portTraffic.logEmpty")}</p>
) : !filteredLogEvents.length ? (
<p className="muted">{t("portTraffic.logEmpty")}</p>
) : (
<div className="pt-log-modal__list">
{(logQuery.data?.items || []).map((ev) => {
{filteredLogEvents.map((ev) => {
const level = String(ev.level || "error").toLowerCase();
const tone =
level === "warn" || level === "warning"

View file

@ -6,7 +6,6 @@ import {
deleteFabricNodes,
fetchFabricNodes,
fetchTopologyTree,
generateTopologySlices,
matchFabricNodes,
patchFabricNodeTags,
} from "../../services/api";
@ -14,7 +13,7 @@ import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { openOrFocusModule } from "../../utils/moduleWindows";
import type { FabricNodeSearchHit, SliceGenerateResult, TopologyTreeFolderItem } from "../../types";
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
const PAGE_SIZE = 50;
@ -37,15 +36,42 @@ function isFabricNodeDeletable(n: FabricNodeSearchHit): boolean {
return src === "lldp" || src === "topology" || src === "webcrt";
}
const ROLE_OPTIONS = ["core", "aggregation", "access", "unknown"] as const;
const LEVEL_PRESETS = [
{ value: "0", labelKey: "topoClassify.levelExternal" },
{ value: "1", labelKey: "topoClassify.levelCore" },
{ value: "1.1", labelKey: "topoClassify.levelCoreSub" },
{ value: "2", labelKey: "topoClassify.levelAgg" },
{ value: "2.1", labelKey: "topoClassify.levelAggSub" },
{ value: "3", labelKey: "topoClassify.levelAccess" },
{ value: "3.1", labelKey: "topoClassify.levelAccessSub" },
] as const;
function roleLabel(t: (k: string) => string, role: string): string {
const s = String(role || "").trim().toLowerCase();
if (s === "core") return t("topoClassify.roleCore");
if (s === "aggregation") return t("topoClassify.roleAggregation");
if (s === "access") return t("topoClassify.roleAccess");
if (s === "unknown") return t("topoClassify.roleUnknown");
return role || "-";
const FILTER_MAJOR = [
{ value: "", labelKey: "topoClassify.filterLevelAll" },
{ value: "0", labelKey: "topoClassify.levelExternal" },
{ value: "1", labelKey: "topoClassify.levelCore" },
{ value: "2", labelKey: "topoClassify.levelAgg" },
{ value: "3", labelKey: "topoClassify.levelAccess" },
] as const;
function formatLevel(level: number | null | undefined): string {
if (level === null || level === undefined || Number.isNaN(Number(level))) return "—";
const n = Number(level);
if (Math.abs(n - Math.round(n)) < 1e-9) return String(Math.round(n));
return String(Math.round(n * 10) / 10);
}
function parseLevelInput(raw: string): number | null {
const s = String(raw || "").trim();
if (!s) return null;
const n = Number(s);
if (!Number.isFinite(n) || n < 0 || n > 99.9) throw new Error("level_invalid");
return Math.round(n * 10) / 10;
}
function levelPresetLabel(t: (k: string) => string, value: string): string {
const hit = LEVEL_PRESETS.find((p) => p.value === value);
return hit ? t(hit.labelKey) : value;
}
function managedSourceLabel(t: (k: string) => string, source: string): string {
@ -64,7 +90,7 @@ export function TopologyClassifyPage() {
const queryClient = useQueryClient();
const [keyword, setKeyword] = useState("");
const [filterRole, setFilterRole] = useState("");
const [filterMajor, setFilterMajor] = useState("");
const [filterRegion, setFilterRegion] = useState("");
const [unmatched, setUnmatched] = useState("");
const [linkStatus, setLinkStatus] = useState("");
@ -89,23 +115,15 @@ export function TopologyClassifyPage() {
const [matchField, setMatchField] = useState<"name" | "ip" | "name_ip">("name");
const [matchedIds, setMatchedIds] = useState<string[]>([]);
const [matchTotal, setMatchTotal] = useState(0);
const [assignRole, setAssignRole] = useState("core");
const [assignLevel, setAssignLevel] = useState("1");
const [assignRegion, setAssignRegion] = useState("");
const [assignWhat, setAssignWhat] = useState<"role" | "region" | "both">("role");
const [assignWhat, setAssignWhat] = useState<"level" | "region" | "both">("level");
const [selected, setSelected] = useState<Record<string, boolean>>({});
const [editingId, setEditingId] = useState("");
const [editRole, setEditRole] = useState("");
const [editLevel, setEditLevel] = useState("");
const [editRegion, setEditRegion] = useState("");
const [sliceFolderId, setSliceFolderId] = useState("");
const [sliceTemplate, setSliceTemplate] = useState<"core_only" | "core_agg" | "agg_access">(
"core_agg",
);
const [slicePreview, setSlicePreview] = useState<SliceGenerateResult | null>(null);
const [seedPhysical, setSeedPhysical] = useState(true);
const [showSlices, setShowSlices] = useState(false);
const treeQuery = useQuery({
queryKey: queryKeys.topologyTree,
queryFn: fetchTopologyTree,
@ -120,16 +138,17 @@ export function TopologyClassifyPage() {
const listQuery = useQuery({
queryKey: queryKeys.fabricNodeInventory(
keyword,
filterRole,
"",
filterRegion,
unmatched,
linkStatus,
page,
filterMajor,
),
queryFn: () =>
fetchFabricNodes({
keyword,
role: filterRole,
levelMajor: filterMajor || undefined,
regionFolderId: filterRegion,
unmatched,
linkStatus,
@ -163,7 +182,9 @@ export function TopologyClassifyPage() {
fabric_node_ids: ids.length ? ids : matchedIds,
dry_run: false,
};
if (assignWhat === "role" || assignWhat === "both") body.role = assignRole;
if (assignWhat === "level" || assignWhat === "both") {
body.level = parseLevelInput(assignLevel);
}
if (assignWhat === "region" || assignWhat === "both") {
body.region_folder_id = assignRegion || "";
}
@ -180,11 +201,18 @@ export function TopologyClassifyPage() {
});
const patchMut = useMutation({
mutationFn: () =>
patchFabricNodeTags(editingId, {
role: editRole,
mutationFn: () => {
let level: number | null;
try {
level = parseLevelInput(editLevel);
} catch {
throw new Error(t("topoClassify.levelInvalid"));
}
return patchFabricNodeTags(editingId, {
level,
region_folder_id: editRegion || "",
}),
});
},
onSuccess: async () => {
showOk(t("topoClassify.rowSaved"));
setEditingId("");
@ -213,36 +241,6 @@ export function TopologyClassifyPage() {
onError: (err) => showError(String(err)),
});
const slicePreviewMut = useMutation({
mutationFn: () =>
generateTopologySlices({
folder_id: sliceFolderId,
template: sliceTemplate,
dry_run: true,
max_nodes: 300,
seed_physical_cores: seedPhysical,
}),
onSuccess: (out) => setSlicePreview(out),
onError: (err) => showError(String(err)),
});
const sliceApplyMut = useMutation({
mutationFn: () =>
generateTopologySlices({
folder_id: sliceFolderId,
template: sliceTemplate,
dry_run: false,
max_nodes: 300,
seed_physical_cores: seedPhysical,
}),
onSuccess: async (out) => {
showOk(t("topoClassify.sliceOk").replace("{{count}}", String(out.created_view_ids.length)));
setSlicePreview(out);
await queryClient.invalidateQueries({ queryKey: queryKeys.topologyTree });
},
onError: (err) => showError(String(err)),
});
const items = listQuery.data?.items || [];
const deletableSelected = items
.filter((n) => selected[n.id] && isFabricNodeDeletable(n))
@ -254,7 +252,7 @@ export function TopologyClassifyPage() {
const startEdit = (n: FabricNodeSearchHit) => {
setEditingId(n.id);
setEditRole(n.role || "");
setEditLevel(n.level == null ? "" : formatLevel(n.level));
setEditRegion(n.region_folder_id || "");
};
@ -278,7 +276,17 @@ export function TopologyClassifyPage() {
<button
type="button"
className="btn-primary"
onClick={() => openOrFocusModule({ moduleId: "topology", path: "/topology" })}
onClick={() => {
const regionId = filterRegion || assignRegion || "";
const folder = regions.find((r) => r.id === regionId);
const views = folder?.views || [];
const primary =
views.find((v) => String(v.kind || "") === "physical") || views[0];
const path = primary?.id
? `/topology?view=${encodeURIComponent(primary.id)}`
: "/topology";
openOrFocusModule({ moduleId: "topology", path });
}}
>
{t("topoClassify.openTopo")}
</button>
@ -299,16 +307,15 @@ export function TopologyClassifyPage() {
/>
<select
className="input"
value={filterRole}
value={filterMajor}
onChange={(e) => {
setFilterRole(e.target.value);
setFilterMajor(e.target.value);
setPage(1);
}}
>
<option value="">{t("topoClassify.filterRoleAll")}</option>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{roleLabel(t, r)}
{FILTER_MAJOR.map((r) => (
<option key={r.value || "all"} value={r.value}>
{t(r.labelKey)}
</option>
))}
</select>
@ -337,7 +344,7 @@ export function TopologyClassifyPage() {
>
<option value="">{t("topoClassify.filterUnmatchedOff")}</option>
<option value="any">{t("topoClassify.kindAny")}</option>
<option value="role">{t("topoClassify.kindRole")}</option>
<option value="level">{t("topoClassify.kindLevel")}</option>
<option value="region">{t("topoClassify.kindRegion")}</option>
</select>
<select
@ -390,26 +397,26 @@ export function TopologyClassifyPage() {
<select
className="input"
value={assignWhat}
onChange={(e) => setAssignWhat(e.target.value as "role" | "region" | "both")}
onChange={(e) => setAssignWhat(e.target.value as "level" | "region" | "both")}
>
<option value="role">{t("topoClassify.assignRole")}</option>
<option value="level">{t("topoClassify.assignLevel")}</option>
<option value="region">{t("topoClassify.assignRegion")}</option>
<option value="both">{t("topoClassify.assignBoth")}</option>
</select>
{assignWhat !== "region" ? (
<select
className="input"
value={assignRole}
onChange={(e) => setAssignRole(e.target.value)}
value={assignLevel}
onChange={(e) => setAssignLevel(e.target.value)}
>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{roleLabel(t, r)}
{LEVEL_PRESETS.map((r) => (
<option key={r.value} value={r.value}>
{levelPresetLabel(t, r.value)}
</option>
))}
</select>
) : null}
{assignWhat !== "role" ? (
{assignWhat !== "level" ? (
<select
className="input"
value={assignRegion}
@ -475,7 +482,7 @@ export function TopologyClassifyPage() {
<th>{t("topoClassify.colNe")}</th>
<th>IP</th>
<th>{t("topoClassify.colLink")}</th>
<th>{t("topoClassify.colRole")}</th>
<th>{t("topoClassify.colLevel")}</th>
<th>{t("topoClassify.colRegion")}</th>
<th>{t("topoClassify.colActions")}</th>
</tr>
@ -503,20 +510,14 @@ export function TopologyClassifyPage() {
</td>
<td>
{editing ? (
<select
<input
className="input"
value={editRole}
onChange={(e) => setEditRole(e.target.value)}
>
<option value="">-</option>
{ROLE_OPTIONS.map((r) => (
<option key={r} value={r}>
{roleLabel(t, r)}
</option>
))}
</select>
value={editLevel}
placeholder="1 / 1.1 / 2.1"
onChange={(e) => setEditLevel(e.target.value)}
/>
) : (
roleLabel(t, n.role || "")
formatLevel(n.level)
)}
</td>
<td>
@ -627,95 +628,6 @@ export function TopologyClassifyPage() {
</button>
</div>
</section>
<section className="topo-classify__section">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setShowSlices((v) => !v)}
>
{showSlices ? t("topoClassify.hideSlices") : t("topoClassify.showSlices")}
</button>
{showSlices ? (
<>
<h3>{t("topoClassify.slices")}</h3>
<div className="topo-classify__draft">
<select
className="input"
value={sliceFolderId}
onChange={(e) => {
setSliceFolderId(e.target.value);
setSlicePreview(null);
}}
>
<option value="">{t("topoClassify.pickRegion")}</option>
{regions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
<select
className="input"
value={sliceTemplate}
onChange={(e) =>
setSliceTemplate(e.target.value as "core_only" | "core_agg" | "agg_access")
}
>
<option value="core_only">{t("topoClassify.tplCore")}</option>
<option value="core_agg">{t("topoClassify.tplCoreAgg")}</option>
<option value="agg_access">{t("topoClassify.tplAggAcc")}</option>
</select>
<label className="topo-classify__check">
<input
type="checkbox"
checked={seedPhysical}
onChange={(e) => setSeedPhysical(e.target.checked)}
/>
{t("topoClassify.seedPhysical")}
</label>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!sliceFolderId || slicePreviewMut.isPending}
onClick={() => slicePreviewMut.mutate()}
>
{t("topoClassify.slicePreview")}
</button>
<button
type="button"
className="btn btn--sm"
disabled={!sliceFolderId || sliceApplyMut.isPending}
onClick={() => {
if (window.confirm(t("topoClassify.sliceConfirm"))) sliceApplyMut.mutate();
}}
>
{t("topoClassify.sliceApply")}
</button>
</div>
{slicePreview ? (
<div className="topo-classify__preview">
<p className="panel__hint">
{t("topoClassify.sliceStats")
.replace("{{maps}}", String(slicePreview.map_count))
.replace("{{overlap}}", String(slicePreview.overlap_node_count))}
</p>
<ul className="topo-classify__slice-list">
{slicePreview.maps.map((m) => (
<li key={m.name}>
<strong>{m.name}</strong>
{" · "}
{roleLabel(t, m.role)}
{" · "}
{t("topoClassify.nodeCount").replace("{{count}}", String(m.node_count))}
</li>
))}
</ul>
</div>
) : null}
</>
) : null}
</section>
</div>
);
}

View file

@ -291,6 +291,7 @@ export type TopologyReactFlowViewProps = {
setCanvasZoom: (z: number) => void;
scheduleFlatViewportRefresh: () => void;
toggleFullscreen: () => void | Promise<void>;
readOnly?: boolean;
};
export default function TopologyReactFlowView(props: TopologyReactFlowViewProps) {

View file

@ -8,6 +8,7 @@ import type { NeNodeData } from "./TopologyReactFlowView";
import type { ToolMode } from "./toolMode";
export type TopologyToolbarProps = {
readOnly?: boolean;
breadcrumbFolders: TopologyTreeFolderItem[];
activeView: TopologyTreeViewItem | null | undefined;
activeRegion: TopologyTreeFolderItem | null | undefined;
@ -51,6 +52,7 @@ export type TopologyToolbarProps = {
};
export function TopologyToolbar({
readOnly = false,
breadcrumbFolders,
activeView,
activeRegion,
@ -186,8 +188,14 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm"
disabled={isWorldFlatCanvas}
title={isWorldFlatCanvas ? t("topology.worldMapNoDirectNes") : undefined}
disabled={readOnly || isWorldFlatCanvas}
title={
readOnly
? t("topology.readOnlyHint")
: isWorldFlatCanvas
? t("topology.worldMapNoDirectNes")
: undefined
}
onClick={onAddNe}
>
{t("topology.addNe")}
@ -195,8 +203,14 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm"
disabled={isWorldFlatCanvas}
title={isWorldFlatCanvas ? t("topology.worldMapNoDirectNes") : undefined}
disabled={readOnly || isWorldFlatCanvas}
title={
readOnly
? t("topology.readOnlyHint")
: isWorldFlatCanvas
? t("topology.worldMapNoDirectNes")
: undefined
}
onClick={handleCreateNe}
>
{t("topology.createNe")}
@ -207,7 +221,7 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!canUndo}
disabled={readOnly || !canUndo}
onClick={onUndo}
title="Ctrl+Z"
>
@ -216,7 +230,7 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!canRedo}
disabled={readOnly || !canRedo}
onClick={onRedo}
title="Ctrl+Y"
>
@ -225,9 +239,9 @@ export function TopologyToolbar({
<button
type="button"
className={`btn btn--sm${dirty ? "" : " btn--ghost"}`}
disabled={savePending || !dirty}
disabled={readOnly || savePending || !dirty}
onClick={onSave}
title="Ctrl+S"
title={readOnly ? t("topology.readOnlyHint") : "Ctrl+S"}
>
{savePending ? t("topology.saving") : dirty ? t("topology.saveDirty") : t("topology.save")}
</button>
@ -265,8 +279,8 @@ export function TopologyToolbar({
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={!staleEdgeCount}
title={t("topology.removeStaleHint")}
disabled={readOnly || !staleEdgeCount}
title={readOnly ? t("topology.readOnlyHint") : t("topology.removeStaleHint")}
onClick={() => void onRemoveStale()}
>
{t("topology.removeStale").replace("{{count}}", String(staleEdgeCount))}
@ -286,8 +300,9 @@ export function TopologyToolbar({
key={mode}
type="button"
className={`topo-tools__btn${toolMode === mode ? " is-active" : ""}`}
title={`${label} (${key})`}
title={`${label} (${key})${readOnly && mode === "connect" ? ` — ${t("topology.readOnlyHint")}` : ""}`}
aria-pressed={toolMode === mode}
disabled={readOnly && mode === "connect"}
onClick={() => {
onToolModeChange(mode);
onConnectClickReset();

View file

@ -1,9 +1,10 @@
import type { RefObject } from "react";
import { createPortal } from "react-dom";
import { useQueryClient } from "@tanstack/react-query";
import type { RefObject } from "react";
import type { Node } from "@xyflow/react";
import { createPortal } from "react-dom";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { updateLldpCollectPolicy } from "../../services/api";
import {
DEFAULT_CANVAS_BG,
@ -87,6 +88,7 @@ export type TopologyViewToolsProps = {
export function TopologyViewTools(props: TopologyViewToolsProps) {
const { t } = useI18n();
const { showError } = useToast();
const queryClient = useQueryClient();
const {
fullscreen,
@ -225,7 +227,7 @@ export function TopologyViewTools(props: TopologyViewToolsProps) {
void queryClient.invalidateQueries({ queryKey: queryKeys.lldpCollectDashboard });
})
.catch(() => {
/* local toggle still applies to this canvas discover */
showError(t("topology.policySyncFailed"));
});
}}
/>

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useQuery, type QueryClient } from "@tanstack/react-query";
import type { Edge, Node, ReactFlowInstance } from "@xyflow/react";
import { fetchTopologyGraph } from "../../../services/api";
import { fetchTopologyGraph, formatErr } from "../../../services/api";
import { queryKeys } from "../../../constants/queryKeys";
import type { TopologyTreeViewItem, TopologyViewGraph, TopologyWorldTransform } from "../../../types";
import {
@ -493,6 +493,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
const showWorldScatter =
isWorldFlatCanvas && worldVisualLod !== "full" && worldScatter.length > 0;
const canvasGraphLoading = Boolean(mapId) && (graphQuery.isPending || !graphQuery.data);
const canvasGraphError = Boolean(mapId) && graphQuery.isError;
const canvasGraphErrorMsg = graphQuery.error ? formatErr(graphQuery.error) : "";
const canvasGraphEmpty =
Boolean(mapId) &&
graphQuery.isSuccess &&
@ -551,6 +553,8 @@ export function useTopologyCanvas(opts: UseTopologyCanvasOptions) {
worldDockMe,
showWorldScatter,
canvasGraphLoading,
canvasGraphError,
canvasGraphErrorMsg,
canvasGraphEmpty,
worldNeedsApply,
canvasGraphRefreshing,

View file

@ -13,6 +13,7 @@ import {
removeTopologyViewNodes,
startLldpDiscover,
stopLldpCollectJob,
formatErr,
} from "../../../services/api";
import type {
TopologyDiscoverJob,
@ -181,6 +182,11 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
setDiscoverJobId(jobStart.id);
let job: TopologyDiscoverJob = jobStart;
let cancelled = false;
const isTerminalStatus = (status: string) =>
status === "done" ||
status === "failed" ||
status === "cancelled" ||
status === "stopped";
for (let i = 0; i < 600; i++) {
if (discoverAbortRef.current) {
cancelled = true;
@ -202,12 +208,7 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
neIp: job.items?.[job.items.length - 1]?.ne_ip || p.neIp,
}));
setDiscoverLiveResults((job.items || []) as TopologyDiscoverNeResult[]);
if (
job.status === "done" ||
job.status === "failed" ||
job.status === "cancelled" ||
job.status === "stopped"
) {
if (isTerminalStatus(job.status)) {
if (job.status === "cancelled" || job.status === "stopped") cancelled = true;
break;
}
@ -227,6 +228,9 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
});
return;
}
if (!isTerminalStatus(job.status)) {
throw new Error(t("topology.discoverTimeout"));
}
if (job.status === "failed") {
throw new Error(job.error || "discover_failed");
}
@ -293,8 +297,9 @@ export function useTopologyDiscover(opts: UseTopologyDiscoverOptions) {
if (discoverAbortRef.current) {
setDiscoverError(t("topology.discoverCancelled"));
} else {
setDiscoverError(String(err));
showError(t("topology.discoverFail").replace("{{detail}}", String(err)));
const detail = formatErr(err);
setDiscoverError(detail);
showError(t("topology.discoverFail").replace("{{detail}}", detail));
}
} finally {
setDiscovering(false);