mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 15:53:20 +08:00
feat: unify network and topology table column filters
Replace top filter bars with shared Excel-style column menus, fix select-all and modal interactions, and use plain dropdown indicators. Apply exact-value filters before pagination and export. Isolate pytest database state and cover filter behavior.
This commit is contained in:
parent
ed820126df
commit
3c9e0efd2b
32 changed files with 1429 additions and 645 deletions
|
|
@ -5,9 +5,9 @@ import { useSearchParams } from "react-router-dom";
|
|||
import { ListPager } from "../../components/ListPager";
|
||||
import { HelpHint } from "../../components/HelpHint";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import { useTableColumnFilters } from "../../hooks/useTableColumnFilters";
|
||||
import { useLocalTableColumnFilters } from "../../hooks/useLocalTableColumnFilters";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { useI18n } from "../../i18n";
|
||||
import {
|
||||
|
|
@ -109,26 +109,16 @@ export function LldpLinksPage() {
|
|||
|
||||
const [jobPage, setJobPage] = useState(1);
|
||||
const [jobPageSize, setJobPageSize] = useState(10);
|
||||
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(10);
|
||||
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 [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
|
||||
const [edgeSource, setEdgeSource] = useState("");
|
||||
const [edgeKeyword, setEdgeKeyword] = useState("");
|
||||
const [edgePage, setEdgePage] = useState(1);
|
||||
const [edgePageSize, setEdgePageSize] = useState(10);
|
||||
const [exportingEdges, setExportingEdges] = useState(false);
|
||||
const debouncedEdgeKeyword = useDebouncedValue(edgeKeyword, 300);
|
||||
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [intervalValue, setIntervalValue] = useState(1);
|
||||
|
|
@ -140,9 +130,16 @@ export function LldpLinksPage() {
|
|||
const [selectedMap, setSelectedMap] = useState<Record<string, ConfigSyncTargetRef>>({});
|
||||
const [policyHydrated, setPolicyHydrated] = useState(false);
|
||||
|
||||
const [targetKeyword, setTargetKeyword] = useState("");
|
||||
const [targetPage, setTargetPage] = useState(1);
|
||||
|
||||
const edgeColumns = useTableColumnFilters("/v1/topology/fabric/edges", () => setEdgePage(1));
|
||||
const jobColumns = useTableColumnFilters("/v1/topology/lldp-collect/jobs", () => setJobPage(1));
|
||||
const itemColumns = useTableColumnFilters(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(expandedJobId)}`, () => setItemPage(1));
|
||||
const targetColumns = useTableColumnFilters("/v1/cli/targets", () => setTargetPage(1), { source: "all" });
|
||||
const unmatchedColumns = useLocalTableColumnFilters(itemDetail?.id || "", itemDetail?.unmatched || [], (row) => ({
|
||||
remote: row.remote_name || row.remote_ip || "", localPort: row.local_port || "", remotePort: row.remote_port || "",
|
||||
}));
|
||||
|
||||
const dashQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectDashboard,
|
||||
queryFn: fetchLldpCollectDashboard,
|
||||
|
|
@ -156,8 +153,7 @@ export function LldpLinksPage() {
|
|||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (!dashQuery.data || policyHydrated) return;
|
||||
if (dashQuery.data && !policyHydrated) {
|
||||
const p = dashQuery.data.policy;
|
||||
setEnabled(Boolean(p.enabled));
|
||||
const hours = Math.max(1, Number(p.interval_hours || (Number(p.interval_days || 1) * 24)));
|
||||
|
|
@ -179,84 +175,48 @@ export function LldpLinksPage() {
|
|||
}
|
||||
setSelectedMap(map);
|
||||
setPolicyHydrated(true);
|
||||
}, [dashQuery.data, policyHydrated]);
|
||||
}
|
||||
|
||||
// Deep link from topology canvas: /network/topology/lldp?job_id=…
|
||||
useEffect(() => {
|
||||
const jobId = String(searchParams.get("job_id") || "").trim();
|
||||
if (!jobId) return;
|
||||
// Apply each deep-link job once; keep URL cleanup separate from local state.
|
||||
const linkedJobId = String(searchParams.get("job_id") || "").trim();
|
||||
const [appliedLinkedJobId, setAppliedLinkedJobId] = useState("");
|
||||
if (linkedJobId && linkedJobId !== appliedLinkedJobId) {
|
||||
setAppliedLinkedJobId(linkedJobId);
|
||||
setItemDetail(null);
|
||||
setItemPage(1);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
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]);
|
||||
setExpandedJobId(linkedJobId);
|
||||
}
|
||||
useEffect(() => {
|
||||
if (!linkedJobId) return;
|
||||
setSearchParams((prev) => {
|
||||
const next = new URLSearchParams(prev);
|
||||
if (next.get("job_id") === linkedJobId) next.delete("job_id");
|
||||
return next;
|
||||
}, { replace: true });
|
||||
}, [linkedJobId, setSearchParams]);
|
||||
|
||||
const jobsQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectJobs(jobPage, jobPageSize, jobStatus, debouncedJobKeyword),
|
||||
queryFn: () =>
|
||||
fetchLldpCollectJobs({
|
||||
page: jobPage,
|
||||
pageSize: jobPageSize,
|
||||
status: jobStatus,
|
||||
keyword: debouncedJobKeyword,
|
||||
}),
|
||||
queryKey: [...queryKeys.lldpCollectJobs(jobPage, jobPageSize, "", ""), jobColumns.key],
|
||||
queryFn: () => fetchLldpCollectJobs({ page: jobPage, pageSize: jobPageSize, columnFilters: jobColumns.filters }),
|
||||
staleTime: 1000,
|
||||
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const jobDetailQuery = useQuery({
|
||||
queryKey: queryKeys.lldpCollectJob(
|
||||
expandedJobId,
|
||||
itemPage,
|
||||
itemPageSize,
|
||||
itemStatus,
|
||||
debouncedItemKeyword,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchLldpCollectJob(expandedJobId, {
|
||||
page: itemPage,
|
||||
pageSize: itemPageSize,
|
||||
status: itemStatus,
|
||||
keyword: debouncedItemKeyword,
|
||||
}),
|
||||
queryKey: [...queryKeys.lldpCollectJob(expandedJobId, itemPage, itemPageSize, "", ""), itemColumns.key],
|
||||
queryFn: () => fetchLldpCollectJob(expandedJobId, { page: itemPage, pageSize: itemPageSize, columnFilters: itemColumns.filters }),
|
||||
enabled: Boolean(expandedJobId),
|
||||
staleTime: 800,
|
||||
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
|
||||
});
|
||||
|
||||
const targetsQuery = useQuery({
|
||||
queryKey: queryKeys.cliTargets(targetKeyword, targetPage, TARGET_PAGE_SIZE),
|
||||
queryFn: () =>
|
||||
fetchCliTargets({ source: "all", keyword: targetKeyword, page: targetPage, pageSize: TARGET_PAGE_SIZE }),
|
||||
queryKey: queryKeys.cliTargets(`all:${targetColumns.key}`, targetPage, TARGET_PAGE_SIZE),
|
||||
queryFn: () => fetchCliTargets({ source: "all", columnFilters: targetColumns.filters, page: targetPage, pageSize: TARGET_PAGE_SIZE }),
|
||||
enabled: scopeMode === "selected",
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
const edgesQuery = useQuery({
|
||||
queryKey: queryKeys.fabricEdges(
|
||||
edgeStatus,
|
||||
debouncedEdgeKeyword,
|
||||
edgePage,
|
||||
edgePageSize,
|
||||
edgeSource,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchFabricEdges({
|
||||
status: edgeStatus === "all" ? "" : edgeStatus,
|
||||
keyword: debouncedEdgeKeyword,
|
||||
source: edgeSource || undefined,
|
||||
page: edgePage,
|
||||
pageSize: edgePageSize,
|
||||
}),
|
||||
queryKey: [...queryKeys.fabricEdges("all", "", edgePage, edgePageSize, ""), edgeColumns.key],
|
||||
queryFn: () => fetchFabricEdges({ columnFilters: edgeColumns.filters, page: edgePage, pageSize: edgePageSize }),
|
||||
staleTime: 2000,
|
||||
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
|
||||
});
|
||||
|
|
@ -368,6 +328,9 @@ export function LldpLinksPage() {
|
|||
const edgeItems = edgesQuery.data?.items ?? [];
|
||||
const edgeTotal = Number(edgesQuery.data?.total || 0);
|
||||
const edgePages = pageCount(edgeTotal, edgePageSize);
|
||||
if (edgesQuery.data && edgePage > edgePages) setEdgePage(edgePages);
|
||||
if (jobsQuery.data && jobPage > jobPages) setJobPage(jobPages);
|
||||
if (jobDetailQuery.data && itemPage > itemPages) setItemPage(itemPages);
|
||||
|
||||
const exportEdgesCsv = async () => {
|
||||
setExportingEdges(true);
|
||||
|
|
@ -377,9 +340,7 @@ export function LldpLinksPage() {
|
|||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchFabricEdges({
|
||||
status: edgeStatus === "all" ? "" : edgeStatus,
|
||||
keyword: debouncedEdgeKeyword,
|
||||
source: edgeSource || undefined,
|
||||
columnFilters: edgeColumns.filters,
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
}),
|
||||
|
|
@ -440,8 +401,7 @@ export function LldpLinksPage() {
|
|||
fetchLldpCollectJobs({
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
status: jobStatus,
|
||||
keyword: debouncedJobKeyword,
|
||||
columnFilters: jobColumns.filters,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
|
|
@ -509,8 +469,7 @@ export function LldpLinksPage() {
|
|||
const job = await fetchLldpCollectJob(expandedJobId, {
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
status: itemStatus,
|
||||
keyword: debouncedItemKeyword,
|
||||
columnFilters: itemColumns.filters,
|
||||
});
|
||||
return { total: job.items_total, items: (job.items || []) as TopologyDiscoverJobItem[] };
|
||||
},
|
||||
|
|
@ -749,24 +708,15 @@ export function LldpLinksPage() {
|
|||
{scopeMode === "selected" ? (
|
||||
<div style={{ marginTop: 12 }}>
|
||||
<p className="muted">{t("lldpLinks.selectedCount", { count: String(selectedCount) })}</p>
|
||||
<div className="filter-inline">
|
||||
<input
|
||||
value={targetKeyword}
|
||||
placeholder={t("lldpLinks.targetKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setTargetKeyword(e.target.value);
|
||||
setTargetPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<table className="data-table">
|
||||
<Button size="sm" variant="tertiary" isDisabled={!targetColumns.active} onPress={targetColumns.reset}>{t("common.clearFilters")}</Button>
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>{t("lldpLinks.col.source")}</th>
|
||||
<th>{t("lldpLinks.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("lldpLinks.col.vendor")}</th>
|
||||
<th>{targetColumns.column("source", t("lldpLinks.col.source"))}</th>
|
||||
<th>{targetColumns.column("name", t("lldpLinks.col.name"))}</th>
|
||||
<th>{targetColumns.column("ip", "IP")}</th>
|
||||
<th>{targetColumns.column("vendor", t("lldpLinks.col.vendor"))}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -823,42 +773,12 @@ export function LldpLinksPage() {
|
|||
</div>
|
||||
|
||||
<h3>{t("lldpLinks.edgesTitle")}</h3>
|
||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||
<FieldSelect
|
||||
value={edgeStatus}
|
||||
onChange={(e) => {
|
||||
const v = e.target.value;
|
||||
setEdgeStatus(v === "active" || v === "missing" ? v : "all");
|
||||
setEdgePage(1);
|
||||
}}
|
||||
>
|
||||
<option value="all">{t("lldpLinks.edgeStatusAll")}</option>
|
||||
<option value="active">{t("lldpLinks.edgeStatusActive")}</option>
|
||||
<option value="missing">{t("lldpLinks.edgeStatusMissing")}</option>
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
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>
|
||||
</FieldSelect>
|
||||
<Input
|
||||
value={edgeKeyword}
|
||||
placeholder={t("lldpLinks.edgeKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setEdgeKeyword(e.target.value);
|
||||
setEdgePage(1);
|
||||
}}
|
||||
/>
|
||||
<div className="btn-row" style={{ marginBottom: 8 }}>
|
||||
<Button size="sm" variant="tertiary" isDisabled={!edgeColumns.active} onPress={edgeColumns.reset}>{t("common.clearFilters")}</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exportingEdges || edgeTotal === 0}
|
||||
isDisabled={exportingEdges || edgeTotal === 0 || edgesQuery.isFetching}
|
||||
onPress={() => void exportEdgesCsv()}
|
||||
>
|
||||
{exportingEdges ? t("common.exporting") : t("common.exportCsv")}
|
||||
|
|
@ -868,14 +788,14 @@ export function LldpLinksPage() {
|
|||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("lldpLinks.col.status")}</th>
|
||||
<th>{t("lldpLinks.col.aSide")}</th>
|
||||
<th>{t("lldpLinks.col.aPort")}</th>
|
||||
<th>{t("lldpLinks.col.bSide")}</th>
|
||||
<th>{t("lldpLinks.col.bPort")}</th>
|
||||
<th>{t("lldpLinks.col.source")}</th>
|
||||
<th>{t("lldpLinks.missCount")}</th>
|
||||
<th>{t("lldpLinks.col.lastSeen")}</th>
|
||||
<th>{edgeColumns.column("status", t("lldpLinks.col.status"), (value) => lldpEdgeStatusLabel(t, value))}</th>
|
||||
<th>{edgeColumns.column("aSide", t("lldpLinks.col.aSide"))}</th>
|
||||
<th>{edgeColumns.column("aPort", t("lldpLinks.col.aPort"))}</th>
|
||||
<th>{edgeColumns.column("bSide", t("lldpLinks.col.bSide"))}</th>
|
||||
<th>{edgeColumns.column("bPort", t("lldpLinks.col.bPort"))}</th>
|
||||
<th>{edgeColumns.column("source", t("lldpLinks.col.source"), (value) => lldpEdgeSourceLabel(t, value))}</th>
|
||||
<th>{edgeColumns.column("missCount", t("lldpLinks.missCount"))}</th>
|
||||
<th>{edgeColumns.column("lastSeen", t("lldpLinks.col.lastSeen"), formatSystemTime)}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -943,35 +863,12 @@ export function LldpLinksPage() {
|
|||
/>
|
||||
|
||||
<h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3>
|
||||
<div className="filter-inline" style={{ marginBottom: 8 }}>
|
||||
<FieldSelect
|
||||
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>
|
||||
</FieldSelect>
|
||||
<Input
|
||||
value={jobKeyword}
|
||||
placeholder={t("lldpLinks.jobKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setJobKeyword(e.target.value);
|
||||
setJobPage(1);
|
||||
}}
|
||||
/>
|
||||
<div className="btn-row" style={{ marginBottom: 8 }}>
|
||||
<Button size="sm" variant="tertiary" isDisabled={!jobColumns.active} onPress={jobColumns.reset}>{t("common.clearFilters")}</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exportingJobs || jobTotal === 0}
|
||||
isDisabled={exportingJobs || jobTotal === 0 || jobsQuery.isFetching}
|
||||
onPress={() => void exportJobsCsv()}
|
||||
>
|
||||
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
|
||||
|
|
@ -982,15 +879,15 @@ export function LldpLinksPage() {
|
|||
<thead>
|
||||
<tr>
|
||||
<th />
|
||||
<th>ID</th>
|
||||
<th>{t("lldpLinks.col.trigger")}</th>
|
||||
<th>{t("lldpLinks.col.scope")}</th>
|
||||
<th>{t("lldpLinks.col.status")}</th>
|
||||
<th>{t("lldpLinks.col.progress")}</th>
|
||||
<th>{t("lldpLinks.col.edges")}</th>
|
||||
<th>{t("lldpLinks.col.missingDelta")}</th>
|
||||
<th>{t("lldpLinks.col.started")}</th>
|
||||
<th>{t("lldpLinks.col.ended")}</th>
|
||||
<th>{jobColumns.column("id", "ID", (value) => value.slice(0, 8))}</th>
|
||||
<th>{jobColumns.column("trigger", t("lldpLinks.col.trigger"), (value) => lldpTriggerLabel(t, value))}</th>
|
||||
<th>{jobColumns.column("scope", t("lldpLinks.col.scope"), (value) => lldpScopeLabel(t, value))}</th>
|
||||
<th>{jobColumns.column("status", t("lldpLinks.col.status"), (value) => lldpJobStatusLabel(t, value))}</th>
|
||||
<th>{jobColumns.column("progress", t("lldpLinks.col.progress"))}</th>
|
||||
<th>{jobColumns.column("edges", t("lldpLinks.col.edges"), (value) => { const [added, updated] = value.split("/"); return `+${added} / ~${updated}`; })}</th>
|
||||
<th>{jobColumns.column("missingDelta", t("lldpLinks.col.missingDelta"))}</th>
|
||||
<th>{jobColumns.column("started", t("lldpLinks.col.started"), formatSystemTime)}</th>
|
||||
<th>{jobColumns.column("ended", t("lldpLinks.col.ended"), formatSystemTime)}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -1004,8 +901,7 @@ export function LldpLinksPage() {
|
|||
variant="ghost"
|
||||
onPress={() => {
|
||||
setItemDetail(null);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
itemColumns.reset();
|
||||
setExpandedJobId(job.id);
|
||||
setItemPage(1);
|
||||
}}
|
||||
|
|
@ -1103,8 +999,7 @@ export function LldpLinksPage() {
|
|||
onClose={() => {
|
||||
setExpandedJobId("");
|
||||
setItemDetail(null);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
itemColumns.reset();
|
||||
setItemPage(1);
|
||||
}}
|
||||
size="lg"
|
||||
|
|
@ -1127,28 +1022,7 @@ export function LldpLinksPage() {
|
|||
})()}
|
||||
</p>
|
||||
|
||||
<div className="filter-inline">
|
||||
<FieldSelect
|
||||
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>
|
||||
</FieldSelect>
|
||||
<Input
|
||||
value={itemKeyword}
|
||||
placeholder={t("lldpLinks.itemKeywordPh")}
|
||||
onChange={(e) => {
|
||||
setItemKeyword(e.target.value);
|
||||
setItemPage(1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<Button size="sm" variant="tertiary" isDisabled={!itemColumns.active} onPress={itemColumns.reset}>{t("common.clearFilters")}</Button>
|
||||
|
||||
{jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{jobDetailQuery.data?.error ? (
|
||||
|
|
@ -1160,12 +1034,12 @@ export function LldpLinksPage() {
|
|||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("lldpLinks.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("lldpLinks.col.status")}</th>
|
||||
<th>{t("lldpLinks.col.neighbors")}</th>
|
||||
<th>{t("lldpLinks.col.edges")}</th>
|
||||
<th>{t("lldpLinks.col.error")}</th>
|
||||
<th>{itemColumns.column("name", t("lldpLinks.col.name"))}</th>
|
||||
<th>{itemColumns.column("ip", "IP")}</th>
|
||||
<th>{itemColumns.column("status", t("lldpLinks.col.status"), (value) => t(`lldpLinks.itemResult.${value}`))}</th>
|
||||
<th>{itemColumns.column("neighbors", t("lldpLinks.col.neighbors"))}</th>
|
||||
<th>{itemColumns.column("edges", t("lldpLinks.col.edges"), (value) => { const [added, updated] = value.split("/"); return `+${added} / ~${updated}`; })}</th>
|
||||
<th>{itemColumns.column("error", t("lldpLinks.col.error"))}</th>
|
||||
<th />
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -1202,7 +1076,7 @@ export function LldpLinksPage() {
|
|||
</td>
|
||||
<td className="muted">{it.error || "—"}</td>
|
||||
<td>
|
||||
<Button size="sm" variant="tertiary" onPress={() => setItemDetail(it)}>
|
||||
<Button size="sm" variant="tertiary" onPress={() => { unmatchedColumns.reset(); setItemDetail(it); }}>
|
||||
{t("topology.discoverViewDetail")}
|
||||
</Button>
|
||||
</td>
|
||||
|
|
@ -1238,7 +1112,7 @@ export function LldpLinksPage() {
|
|||
<Modal.Footer>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={exportingItems || itemTotal === 0}
|
||||
isDisabled={exportingItems || itemTotal === 0 || jobDetailQuery.isFetching}
|
||||
onPress={() => void exportItemsCsv()}
|
||||
>
|
||||
{exportingItems ? t("common.exporting") : t("common.exportCsv")}
|
||||
|
|
@ -1248,8 +1122,7 @@ export function LldpLinksPage() {
|
|||
onPress={() => {
|
||||
setExpandedJobId("");
|
||||
setItemDetail(null);
|
||||
setItemStatus("");
|
||||
setItemKeyword("");
|
||||
itemColumns.reset();
|
||||
setItemPage(1);
|
||||
}}
|
||||
>
|
||||
|
|
@ -1307,16 +1180,16 @@ export function LldpLinksPage() {
|
|||
<p className="panel__hint">{t("topology.discoverUnmatchedEmpty")}</p>
|
||||
) : (
|
||||
<div className="topo-discover-modal__table-wrap">
|
||||
<table className="topo-discover-modal__table">
|
||||
<table className="data-table pt-list-table topo-discover-modal__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("topology.discoverColRemote")}</th>
|
||||
<th>{t("topology.discoverColLocalPort")}</th>
|
||||
<th>{t("topology.discoverColRemotePort")}</th>
|
||||
<th>{unmatchedColumns.column("remote", t("topology.discoverColRemote"))}</th>
|
||||
<th>{unmatchedColumns.column("localPort", t("topology.discoverColLocalPort"))}</th>
|
||||
<th>{unmatchedColumns.column("remotePort", t("topology.discoverColRemotePort"))}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{(itemDetail.unmatched || []).map((u, idx) => (
|
||||
{unmatchedColumns.items.map((u, idx) => (
|
||||
<tr key={`${itemDetail.id}-u-${idx}`}>
|
||||
<td>
|
||||
{(u.remote_name || u.remote_ip || "?").trim()}
|
||||
|
|
@ -1326,6 +1199,7 @@ export function LldpLinksPage() {
|
|||
<td>{u.remote_port || "?"}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!unmatchedColumns.items.length ? <tr><td colSpan={3}>{t("common.empty")}</td></tr> : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { Button, Input } from "@heroui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button } from "@heroui/react";
|
||||
import { useState } from "react";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import { useTableColumnFilters } from "../../hooks/useTableColumnFilters";
|
||||
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";
|
||||
|
|
@ -19,59 +18,34 @@ import { NmStatusChip, severityChipColor } from "../../components/NmChips";
|
|||
export function NetworkAlarmsPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const [searchParams] = useSearchParams();
|
||||
const [curSeverity, setCurSeverity] = useState("");
|
||||
const [curCleared, setCurCleared] = useState("");
|
||||
const [curHostName, setCurHostName] = useState("");
|
||||
const [curKeyword, setCurKeyword] = useState("");
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const curSeverity = searchParams.get("severity") || "";
|
||||
const curCleared = searchParams.get("cleared") || "";
|
||||
const curHostName = searchParams.get("host") || "";
|
||||
const curKeyword = searchParams.get("keyword") || "";
|
||||
const [curPage, setCurPage] = useState(1);
|
||||
const [curPageSize, setCurPageSize] = useState(10);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
// Deep-link from topology (and similar): ?host=&cleared=&severity=&keyword=
|
||||
useEffect(() => {
|
||||
const host = searchParams.get("host");
|
||||
const cleared = searchParams.get("cleared");
|
||||
const severity = searchParams.get("severity");
|
||||
const keyword = searchParams.get("keyword");
|
||||
let touched = false;
|
||||
if (host != null) {
|
||||
setCurHostName(host);
|
||||
touched = true;
|
||||
}
|
||||
if (cleared != null) {
|
||||
setCurCleared(cleared);
|
||||
touched = true;
|
||||
}
|
||||
if (severity != null) {
|
||||
setCurSeverity(severity);
|
||||
touched = true;
|
||||
}
|
||||
if (keyword != null) {
|
||||
setCurKeyword(keyword);
|
||||
touched = true;
|
||||
}
|
||||
if (touched) setCurPage(1);
|
||||
}, [searchParams]);
|
||||
|
||||
const debouncedKeyword = useDebouncedValue(curKeyword, 300);
|
||||
const debouncedHost = useDebouncedValue(curHostName, 300);
|
||||
const columns = useTableColumnFilters("/v1/ume/alarms", () => setCurPage(1), {
|
||||
severity: curSeverity, is_cleared: curCleared, host_name: curHostName, keyword: curKeyword,
|
||||
});
|
||||
|
||||
const currentQuery = useQuery({
|
||||
queryKey: queryKeys.umeCurrentAlarms(
|
||||
queryKey: [...queryKeys.umeCurrentAlarms(
|
||||
curSeverity,
|
||||
curCleared,
|
||||
debouncedHost,
|
||||
debouncedKeyword,
|
||||
curHostName,
|
||||
curKeyword,
|
||||
curPage,
|
||||
curPageSize,
|
||||
),
|
||||
), columns.key],
|
||||
queryFn: () =>
|
||||
fetchUmeCurrentAlarms({
|
||||
columnFilters: columns.filters,
|
||||
severity: curSeverity,
|
||||
isCleared: curCleared,
|
||||
hostName: debouncedHost,
|
||||
keyword: debouncedKeyword,
|
||||
hostName: curHostName,
|
||||
keyword: curKeyword,
|
||||
page: curPage,
|
||||
pageSize: curPageSize,
|
||||
}),
|
||||
|
|
@ -81,7 +55,8 @@ export function NetworkAlarmsPage() {
|
|||
const items = currentQuery.data?.items || [];
|
||||
const curTotal = Number(currentQuery.data?.total || 0);
|
||||
const curPages = pageCount(curTotal, curPageSize);
|
||||
const hasFilters = Boolean(curKeyword.trim() || curHostName.trim() || curSeverity || curCleared);
|
||||
const hasFilters = columns.active || Boolean(curKeyword.trim() || curHostName.trim() || curSeverity || curCleared);
|
||||
if (currentQuery.data && curPage > curPages) setCurPage(curPages);
|
||||
|
||||
const exportCsv = async () => {
|
||||
setExporting(true);
|
||||
|
|
@ -91,10 +66,11 @@ export function NetworkAlarmsPage() {
|
|||
maxRows: 2000,
|
||||
fetchPage: (page, pageSize) =>
|
||||
fetchUmeCurrentAlarms({
|
||||
columnFilters: columns.filters,
|
||||
severity: curSeverity,
|
||||
isCleared: curCleared,
|
||||
hostName: debouncedHost,
|
||||
keyword: debouncedKeyword,
|
||||
hostName: curHostName,
|
||||
keyword: curKeyword,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
|
|
@ -128,10 +104,14 @@ export function NetworkAlarmsPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2>{t("ume.alarms.title")}</h2>
|
||||
<div className="btn-row">
|
||||
{curHostName || curKeyword ? <span className="muted">{curHostName || curKeyword}</span> : null}
|
||||
<Button size="sm" variant="tertiary" isDisabled={!hasFilters} onPress={() => { columns.reset(); setSearchParams({}); }}>
|
||||
{t("common.clearFilters")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exporting || curTotal === 0}
|
||||
isDisabled={exporting || curTotal === 0 || currentQuery.isFetching}
|
||||
onPress={() => void exportCsv()}
|
||||
>
|
||||
{exporting ? t("common.exporting") : t("common.exportCsv")}
|
||||
|
|
@ -140,83 +120,20 @@ export function NetworkAlarmsPage() {
|
|||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<Input
|
||||
value={curKeyword}
|
||||
placeholder={t("ume.alarms.keywordPh")}
|
||||
onChange={(e) => {
|
||||
setCurKeyword(e.target.value);
|
||||
setCurPage(1);
|
||||
}}
|
||||
/>
|
||||
<Input
|
||||
value={curHostName}
|
||||
placeholder={t("ume.alarms.hostNamePh")}
|
||||
onChange={(e) => {
|
||||
setCurHostName(e.target.value);
|
||||
setCurPage(1);
|
||||
}}
|
||||
/>
|
||||
<FieldSelect
|
||||
value={curSeverity}
|
||||
onChange={(e) => {
|
||||
setCurSeverity(e.target.value);
|
||||
setCurPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("ume.alarms.allSeverity")}</option>
|
||||
<option value="critical">critical</option>
|
||||
<option value="major">major</option>
|
||||
<option value="minor">minor</option>
|
||||
<option value="warning">warning</option>
|
||||
<option value="info">info</option>
|
||||
</FieldSelect>
|
||||
<FieldSelect
|
||||
value={curCleared}
|
||||
onChange={(e) => {
|
||||
setCurCleared(e.target.value);
|
||||
setCurPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("ume.alarms.clearedAll")}</option>
|
||||
<option value="false">{t("ume.alarms.clearedNo")}</option>
|
||||
<option value="true">{t("ume.alarms.clearedYes")}</option>
|
||||
</FieldSelect>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
aria-label={t("ume.alarms.clearTitle")}
|
||||
onPress={() => {
|
||||
setCurKeyword("");
|
||||
setCurHostName("");
|
||||
setCurSeverity("");
|
||||
setCurCleared("");
|
||||
setCurPage(1);
|
||||
}}
|
||||
isDisabled={!hasFilters}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{currentQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{currentQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
|
||||
|
||||
{!items.length && !currentQuery.isLoading ? (
|
||||
<div className="pt-list-empty">
|
||||
<p>{t("common.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<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>
|
||||
<th>{columns.column("time", t("ume.alarms.col.time"), formatSystemTime)}</th>
|
||||
<th>{columns.column("severity", t("ume.alarms.col.severity"))}</th>
|
||||
<th>{columns.column("neId", t("ume.alarms.col.neId"))}</th>
|
||||
<th>{columns.column("hostName", t("ume.alarms.col.hostName"))}</th>
|
||||
<th>{columns.column("neType", t("ume.alarms.col.neType"))}</th>
|
||||
<th>{columns.column("cause", t("ume.alarms.col.cause"))}</th>
|
||||
<th>{columns.column("cleared", t("ume.alarms.col.cleared"), (value) => t(value === "true" ? "ume.alarms.clearedYes" : "ume.alarms.clearedNo"))}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
|
|
@ -236,9 +153,7 @@ export function NetworkAlarmsPage() {
|
|||
variant="ghost"
|
||||
className="link-btn pt-list-task-name"
|
||||
onPress={() => {
|
||||
setCurHostName(x.host_name || "");
|
||||
setCurKeyword("");
|
||||
setCurPage(1);
|
||||
columns.setFilter("hostName", { mode: "include", values: [x.host_name || ""] });
|
||||
}}
|
||||
aria-label={t("ume.alarms.filterByHost")}
|
||||
>
|
||||
|
|
@ -252,12 +167,15 @@ export function NetworkAlarmsPage() {
|
|||
</td>
|
||||
<td>{x.ne_type ?? ""}</td>
|
||||
<td>{x.native_probable_cause}</td>
|
||||
<td>{t(x.is_cleared === "true" ? "ume.alarms.clearedYes" : "ume.alarms.clearedNo")}</td>
|
||||
</tr>
|
||||
))}
|
||||
{!items.length && !currentQuery.isFetching && !currentQuery.isError ? (
|
||||
<tr><td colSpan={7} className="pt-list-empty">{t("common.empty")}</td></tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ListPager
|
||||
page={curPage}
|
||||
|
|
|
|||
|
|
@ -1,12 +1,11 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Modal } from "@heroui/react";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { useSearchParams } from "react-router-dom";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import { useTableColumnFilters } from "../../hooks/useTableColumnFilters";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { useI18n } from "../../i18n";
|
||||
import {
|
||||
|
|
@ -35,41 +34,22 @@ export function NetworkConfigsPage() {
|
|||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [keyword, setKeyword] = useState(() => String(searchParams.get("q") || ""));
|
||||
const [source, setSource] = useState("");
|
||||
const [vendor, setVendor] = useState("");
|
||||
const keyword = searchParams.get("q") || "";
|
||||
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 columns = useTableColumnFilters("/v1/config-sync/snapshots", () => setPage(1), { keyword });
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.networkConfigs(page, debouncedKeyword, source, debouncedVendor, pageSize),
|
||||
queryKey: [...queryKeys.networkConfigs(page, keyword, "", "", pageSize), columns.key],
|
||||
queryFn: () =>
|
||||
fetchNeConfigSnapshots({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: debouncedKeyword,
|
||||
source,
|
||||
vendor: debouncedVendor,
|
||||
keyword,
|
||||
columnFilters: columns.filters,
|
||||
}),
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
|
@ -86,7 +66,8 @@ export function NetworkConfigsPage() {
|
|||
const pages = pageCount(total, pageSize);
|
||||
const detail = detailQuery.data;
|
||||
const showAlt = Boolean(detail?.has_alt);
|
||||
const hasFilters = Boolean(keyword || source || vendor);
|
||||
const hasFilters = Boolean(keyword) || columns.active;
|
||||
if (listQuery.data && page > pages) setPage(pages);
|
||||
|
||||
const exportConfig = async (
|
||||
src: string,
|
||||
|
|
@ -115,9 +96,8 @@ export function NetworkConfigsPage() {
|
|||
fetchNeConfigSnapshots({
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
keyword: debouncedKeyword,
|
||||
source,
|
||||
vendor: debouncedVendor,
|
||||
keyword,
|
||||
columnFilters: columns.filters,
|
||||
}),
|
||||
});
|
||||
downloadCsv(
|
||||
|
|
@ -154,10 +134,14 @@ export function NetworkConfigsPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2>{t("networkConfigs.title")}</h2>
|
||||
<div className="btn-row">
|
||||
{keyword ? <span className="muted">{keyword}</span> : null}
|
||||
<Button size="sm" variant="tertiary" isDisabled={!hasFilters} onPress={() => { columns.reset(); setSearchParams({}); }}>
|
||||
{t("common.clearFilters")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exportingList || total === 0}
|
||||
isDisabled={exportingList || total === 0 || listQuery.isFetching}
|
||||
onPress={() => void exportListCsv()}
|
||||
>
|
||||
{exportingList ? t("common.exporting") : t("common.exportCsv")}
|
||||
|
|
@ -166,67 +150,19 @@ export function NetworkConfigsPage() {
|
|||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder={t("networkConfigs.keywordPh")}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<FieldSelect
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("networkConfigs.allSource")}</option>
|
||||
<option value="managed">managed</option>
|
||||
<option value="ume">ume</option>
|
||||
</FieldSelect>
|
||||
<Input
|
||||
value={vendor}
|
||||
placeholder={t("networkConfigs.vendorPh")}
|
||||
onChange={(e) => {
|
||||
setVendor(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
isDisabled={!hasFilters}
|
||||
onPress={() => {
|
||||
setKeyword("");
|
||||
setSource("");
|
||||
setVendor("");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</Button>
|
||||
</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">
|
||||
<p>{t("networkConfigs.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("networkConfigs.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("networkConfigs.col.vendor")}</th>
|
||||
<th>{t("networkConfigs.col.source")}</th>
|
||||
<th>{t("networkConfigs.col.size")}</th>
|
||||
<th>{t("networkConfigs.col.collected")}</th>
|
||||
<th>{columns.column("name", t("networkConfigs.col.name"))}</th>
|
||||
<th>{columns.column("ip", "IP")}</th>
|
||||
<th>{columns.column("vendor", t("networkConfigs.col.vendor"))}</th>
|
||||
<th>{columns.column("source", t("networkConfigs.col.source"))}</th>
|
||||
<th>{columns.column("size", t("networkConfigs.col.size"), (value) => fmtBytes(Number(value)))}</th>
|
||||
<th>{columns.column("collected", t("networkConfigs.col.collected"), formatSystemTime)}</th>
|
||||
<th>{t("portTraffic.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -292,10 +228,12 @@ export function NetworkConfigsPage() {
|
|||
</tr>
|
||||
);
|
||||
})}
|
||||
{!items.length && !listQuery.isFetching && !listQuery.isError ? (
|
||||
<tr><td colSpan={7} className="pt-list-empty">{t("networkConfigs.empty")}</td></tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ListPager
|
||||
page={page}
|
||||
|
|
|
|||
|
|
@ -1,14 +1,13 @@
|
|||
import { Button, Input } from "@heroui/react";
|
||||
import { Button } from "@heroui/react";
|
||||
import { useState } from "react";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import { ColumnFilterMenu } from "../../components/ui/ColumnFilterMenu";
|
||||
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 { fetchCliTargetFilterOptions, fetchCliTargets, formatErr } from "../../services/api";
|
||||
import type { CliTargetColumnFilter, CliTargetFilterField, CliTargetItem } from "../../types";
|
||||
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
|
||||
import { pageCount } from "../../utils/display";
|
||||
import { openNewModuleWindow } from "../../utils/moduleWindows";
|
||||
|
|
@ -22,28 +21,50 @@ function openWebcrt(row: CliTargetItem) {
|
|||
openNewModuleWindow({ moduleId: "webcrt", path });
|
||||
}
|
||||
|
||||
type DeviceColumnFilters = Record<CliTargetFilterField, CliTargetColumnFilter>;
|
||||
|
||||
const EMPTY_COLUMN_FILTERS: DeviceColumnFilters = {
|
||||
source: null,
|
||||
name: null,
|
||||
ip: null,
|
||||
vendor: null,
|
||||
deviceType: null,
|
||||
connectStatus: null,
|
||||
};
|
||||
|
||||
/** 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 [columnFilters, setColumnFilters] = useState<DeviceColumnFilters>(EMPTY_COLUMN_FILTERS);
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
const [exporting, setExporting] = useState(false);
|
||||
|
||||
const debouncedKeyword = useDebouncedValue(keyword, 300);
|
||||
|
||||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.cliTargets(`${source}:${debouncedKeyword}`, page, pageSize),
|
||||
queryFn: () => fetchCliTargets({ source, keyword: debouncedKeyword, page, pageSize }),
|
||||
queryKey: queryKeys.cliTargets(
|
||||
JSON.stringify(columnFilters),
|
||||
page,
|
||||
pageSize,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchCliTargets({
|
||||
source: "inventory",
|
||||
columnFilters,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const total = Number(listQuery.data?.total || 0);
|
||||
const pages = pageCount(total, pageSize);
|
||||
const hasFilters = Boolean(keyword.trim() || source !== "all");
|
||||
const hasFilters =
|
||||
Object.values(columnFilters).some(Boolean);
|
||||
|
||||
if (listQuery.data && page > pages) setPage(pages);
|
||||
|
||||
const exportCsv = async () => {
|
||||
setExporting(true);
|
||||
|
|
@ -52,7 +73,12 @@ export function NetworkDevicesPage() {
|
|||
pageSize: 200,
|
||||
maxRows: 2000,
|
||||
fetchPage: (p, ps) =>
|
||||
fetchCliTargets({ source, keyword: debouncedKeyword, page: p, pageSize: ps }),
|
||||
fetchCliTargets({
|
||||
source: "inventory",
|
||||
columnFilters,
|
||||
page: p,
|
||||
pageSize: ps,
|
||||
}),
|
||||
});
|
||||
downloadCsv(`${t("networkDevices.exportName")}-${new Date().toISOString().slice(0, 10)}.csv`, rows, [
|
||||
{ key: "source", header: t("networkDevices.col.source") },
|
||||
|
|
@ -84,10 +110,13 @@ export function NetworkDevicesPage() {
|
|||
<div className="panel__toolbar">
|
||||
<h2>{t("networkDevices.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<Button size="sm" variant="tertiary" isDisabled={!hasFilters} onPress={() => { setColumnFilters(EMPTY_COLUMN_FILTERS); setPage(1); }}>
|
||||
{t("common.clearFilters")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exporting || total === 0}
|
||||
isDisabled={exporting || total === 0 || listQuery.isFetching}
|
||||
onPress={() => void exportCsv()}
|
||||
>
|
||||
{exporting ? t("common.exporting") : t("common.exportCsv")}
|
||||
|
|
@ -96,101 +125,88 @@ export function NetworkDevicesPage() {
|
|||
</div>
|
||||
|
||||
<div className="pt-list">
|
||||
<div className="filter-inline">
|
||||
<Input
|
||||
value={keyword}
|
||||
placeholder={t("networkDevices.keywordPh")}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<FieldSelect
|
||||
value={source}
|
||||
onChange={(e) => {
|
||||
setSource(e.target.value as "all" | "managed" | "ume");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="all">{t("networkDevices.allSource")}</option>
|
||||
<option value="managed">managed</option>
|
||||
<option value="ume">ume</option>
|
||||
</FieldSelect>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="tertiary"
|
||||
isDisabled={!hasFilters}
|
||||
onPress={() => {
|
||||
setKeyword("");
|
||||
setSource("all");
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{t("common.clearFilters")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{listQuery.isFetching ? <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">
|
||||
<p>{t("networkDevices.empty")}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{t("networkDevices.col.source")}</th>
|
||||
<th>{t("networkDevices.col.name")}</th>
|
||||
<th>IP</th>
|
||||
<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>
|
||||
{items.map((row) => (
|
||||
<tr key={`${row.source}:${row.id}`}>
|
||||
<td>
|
||||
<NmStatusChip color={sourceChipColor(row.source)}>{row.source}</NmStatusChip>
|
||||
</td>
|
||||
<td className="pt-list-task-name">{row.name || row.id}</td>
|
||||
<td className="pt-list-num">{row.ip_address || "—"}</td>
|
||||
<td>{row.vendor || "—"}</td>
|
||||
<td>{row.device_type || row.ne_type || "—"}</td>
|
||||
<td>
|
||||
<NmStatusChip color={connectChipColor(row.connect_status)}>
|
||||
{row.connect_status || "—"}
|
||||
</NmStatusChip>
|
||||
</td>
|
||||
<td>
|
||||
<div className="btn-row pt-list-actions table-actions">
|
||||
<Button size="sm" variant="ghost" onPress={() => openWebcrt(row)}>
|
||||
WebCRT
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() =>
|
||||
openNewModuleWindow({
|
||||
moduleId: "network",
|
||||
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("network.nav.configs")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table">
|
||||
<thead>
|
||||
<tr>
|
||||
{([
|
||||
["source", t("networkDevices.col.source")],
|
||||
["name", t("networkDevices.col.name")],
|
||||
["ip", "IP"],
|
||||
["vendor", t("networkDevices.col.vendor")],
|
||||
["deviceType", t("networkDevices.col.deviceType")],
|
||||
["connectStatus", t("networkDevices.col.connect")],
|
||||
] as const).map(([key, label]) => (
|
||||
<th key={key}>
|
||||
<span className="ne-column-heading">
|
||||
{label}
|
||||
<ColumnFilterMenu
|
||||
label={label}
|
||||
value={columnFilters[key]}
|
||||
queryKey={["cliTargetFilterOptions", key, "inventory", ""]}
|
||||
loadOptions={() => fetchCliTargetFilterOptions({ field: key, source: "inventory" }).then((result) => result.options)}
|
||||
onApply={(value) => {
|
||||
setColumnFilters((current) => ({ ...current, [key]: value }));
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</th>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
<th>{t("networkDevices.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => (
|
||||
<tr key={`${row.source}:${row.id}`}>
|
||||
<td>
|
||||
<NmStatusChip color={sourceChipColor(row.source)}>{row.source}</NmStatusChip>
|
||||
</td>
|
||||
<td className="pt-list-task-name">{row.name || row.id}</td>
|
||||
<td className="pt-list-num">{row.ip_address || "—"}</td>
|
||||
<td>{row.vendor || "—"}</td>
|
||||
<td>{row.device_type || row.ne_type || "—"}</td>
|
||||
<td>
|
||||
<NmStatusChip color={connectChipColor(row.connect_status)}>
|
||||
{row.connect_status || "—"}
|
||||
</NmStatusChip>
|
||||
</td>
|
||||
<td>
|
||||
<div className="btn-row pt-list-actions table-actions">
|
||||
<Button size="sm" variant="ghost" onPress={() => openWebcrt(row)}>
|
||||
WebCRT
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() =>
|
||||
openNewModuleWindow({
|
||||
moduleId: "network",
|
||||
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
|
||||
})
|
||||
}
|
||||
>
|
||||
{t("network.nav.configs")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{!items.length && !listQuery.isFetching && !listQuery.isError ? (
|
||||
<tr>
|
||||
<td colSpan={7}>
|
||||
<div className="pt-list-empty">
|
||||
<p>{t(hasFilters ? "networkDevices.emptyFiltered" : "networkDevices.empty")}</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ListPager
|
||||
page={page}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ import {
|
|||
matchFabricNodes,
|
||||
patchFabricNodeTags,
|
||||
} from "../../services/api";
|
||||
import { useTableColumnFilters } from "../../hooks/useTableColumnFilters";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
|
|
@ -49,14 +50,6 @@ const LEVEL_PRESETS = [
|
|||
{ value: "3.1", labelKey: "topoClassify.levelAccessSub" },
|
||||
] as const;
|
||||
|
||||
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);
|
||||
|
|
@ -92,11 +85,6 @@ export function TopologyClassifyPage() {
|
|||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const [keyword, setKeyword] = useState("");
|
||||
const [filterMajor, setFilterMajor] = useState("");
|
||||
const [filterRegion, setFilterRegion] = useState("");
|
||||
const [unmatched, setUnmatched] = useState("");
|
||||
const [linkStatus, setLinkStatus] = useState("");
|
||||
const [page, setPage] = useState(1);
|
||||
const [pageSize, setPageSize] = useState(10);
|
||||
|
||||
|
|
@ -139,27 +127,12 @@ export function TopologyClassifyPage() {
|
|||
return m;
|
||||
}, [regions]);
|
||||
|
||||
const columns = useTableColumnFilters("/v1/topology/fabric/nodes", () => {
|
||||
setPage(1); setSelected({}); setEditingId("");
|
||||
});
|
||||
const listQuery = useQuery({
|
||||
queryKey: queryKeys.fabricNodeInventory(
|
||||
keyword,
|
||||
"",
|
||||
filterRegion,
|
||||
unmatched,
|
||||
linkStatus,
|
||||
page,
|
||||
filterMajor,
|
||||
pageSize,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchFabricNodes({
|
||||
keyword,
|
||||
levelMajor: filterMajor || undefined,
|
||||
regionFolderId: filterRegion,
|
||||
unmatched,
|
||||
linkStatus,
|
||||
page,
|
||||
pageSize,
|
||||
}),
|
||||
queryKey: ["fabricNodeInventory", columns.key, page, pageSize],
|
||||
queryFn: () => fetchFabricNodes({ columnFilters: columns.filters, page, pageSize }),
|
||||
});
|
||||
|
||||
const invalidateList = async () => {
|
||||
|
|
@ -252,6 +225,7 @@ export function TopologyClassifyPage() {
|
|||
.map((n) => n.id);
|
||||
const total = listQuery.data?.total || 0;
|
||||
const pages = pageCount(total, pageSize);
|
||||
if (listQuery.data && page > pages) setPage(pages);
|
||||
const selectedCount = Object.values(selected).filter(Boolean).length;
|
||||
const highlight = useMemo(() => new Set(matchedIds), [matchedIds]);
|
||||
|
||||
|
|
@ -281,7 +255,8 @@ export function TopologyClassifyPage() {
|
|||
<Button
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
const regionId = filterRegion || assignRegion || "";
|
||||
const regionFilter = columns.filters.region;
|
||||
const regionId = (regionFilter?.mode === "include" && regionFilter.values.length === 1 ? regionFilter.values[0] : "") || assignRegion || "";
|
||||
const folder = regions.find((r) => r.id === regionId);
|
||||
const views = folder?.views || [];
|
||||
const primary =
|
||||
|
|
@ -298,68 +273,10 @@ export function TopologyClassifyPage() {
|
|||
</div>
|
||||
|
||||
<section className="topo-classify__section topo-classify__section--first">
|
||||
<h3>{t("topoClassify.inventory")}</h3>
|
||||
<div className="topo-classify__draft">
|
||||
<Input
|
||||
placeholder={t("topoClassify.keywordPh")}
|
||||
value={keyword}
|
||||
onChange={(e) => {
|
||||
setKeyword(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
/>
|
||||
<select className="input"
|
||||
value={filterMajor}
|
||||
onChange={(e) => {
|
||||
setFilterMajor(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
{FILTER_MAJOR.map((r) => (
|
||||
<option key={r.value || "all"} value={r.value}>
|
||||
{t(r.labelKey)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="input"
|
||||
value={filterRegion}
|
||||
onChange={(e) => {
|
||||
setFilterRegion(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("topoClassify.filterRegionAll")}</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<select className="input"
|
||||
value={unmatched}
|
||||
onChange={(e) => {
|
||||
setUnmatched(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("topoClassify.filterUnmatchedOff")}</option>
|
||||
<option value="any">{t("topoClassify.kindAny")}</option>
|
||||
<option value="level">{t("topoClassify.kindLevel")}</option>
|
||||
<option value="region">{t("topoClassify.kindRegion")}</option>
|
||||
</select>
|
||||
<select className="input"
|
||||
value={linkStatus}
|
||||
onChange={(e) => {
|
||||
setLinkStatus(e.target.value);
|
||||
setPage(1);
|
||||
}}
|
||||
>
|
||||
<option value="">{t("topoClassify.filterLinkAll")}</option>
|
||||
<option value="linked">{t("topoClassify.filterLinkLinked")}</option>
|
||||
<option value="orphaned">{t("topoClassify.filterLinkOrphaned")}</option>
|
||||
</select>
|
||||
<div className="panel__toolbar">
|
||||
<h3>{t("topoClassify.inventory")}</h3>
|
||||
<Button size="sm" variant="tertiary" isDisabled={!columns.active} onPress={columns.reset}>{t("common.clearFilters")}</Button>
|
||||
</div>
|
||||
|
||||
<div className="topo-classify__regex-bar">
|
||||
<strong>{t("topoClassify.regexTitle")}</strong>
|
||||
<Input
|
||||
|
|
@ -456,7 +373,8 @@ export function TopologyClassifyPage() {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
<table className="data-table topo-classify__table">
|
||||
<div className="pt-list-table-wrap">
|
||||
<table className="data-table pt-list-table topo-classify__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
|
|
@ -467,11 +385,11 @@ export function TopologyClassifyPage() {
|
|||
aria-label={t("topoClassify.selectPage")}
|
||||
/>
|
||||
</th>
|
||||
<th>{t("topoClassify.colNe")}</th>
|
||||
<th>IP</th>
|
||||
<th>{t("topoClassify.colLink")}</th>
|
||||
<th>{t("topoClassify.colLevel")}</th>
|
||||
<th>{t("topoClassify.colRegion")}</th>
|
||||
<th>{columns.column("name", t("topoClassify.colNe"))}</th>
|
||||
<th>{columns.column("ip", "IP")}</th>
|
||||
<th>{columns.column("link", t("topoClassify.colLink"), linkLabel)}</th>
|
||||
<th>{columns.column("level", t("topoClassify.colLevel"), (value) => formatLevel(Number(value)))}</th>
|
||||
<th>{columns.column("region", t("topoClassify.colRegion"), (value) => regionName[value] || value)}</th>
|
||||
<th>{t("topoClassify.colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
|
@ -573,6 +491,7 @@ export function TopologyClassifyPage() {
|
|||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<ListPager
|
||||
page={page}
|
||||
pages={pages}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue