mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 09:50:44 +08:00
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:
parent
b1aee86701
commit
b81e5869a6
91 changed files with 10395 additions and 1513 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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 ? (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue