mirror of
https://github.com/hansjone/netx.git
synced 2026-10-16 19:40:47 +08:00
Refactor web UI: feature-first structure, tokenized styles, modern blue theme.
Structure
- pages/ -> features/<domain>/{Page, components, hooks, lib}; 95 files moved,
333 imports rewritten, verified with tsc.
- services/api.ts -> services/api/<domain>.ts (250 exports preserved),
types.ts -> types/<domain>.ts, i18n zh/en -> locales/<lang>/<namespace>.ts
(2696 messages deep-equal before/after).
- ESLint rule: shared layers must not import features/.
Styles
- index.css (16k lines) split losslessly into styles/{base,layout,components,
pages,legacy,theme}; compiled CSS was byte-identical before tokenizing.
- ~930 hard-coded colors replaced by --nx-* tokens (theme/tokens.css); WebCRT
off-palette colors unified; fixed dark-on-dark text introduced by mapping.
- theme/theme.css: modern blue design language (rounded shapes, gradients,
unified buttons/fields/tables/modals), form-grid alignment fix, flat nav list
without tree indent, soft selected state instead of left accent bar.
Docs: WEB.md documents code structure and style rules.
Not touched: the large Biz*/Webcrt/Topology page components (to be split later).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3b45d6abe3
commit
0d894efe5e
196 changed files with 26947 additions and 28091 deletions
1351
web/src/features/network/LldpLinksPage.tsx
Normal file
1351
web/src/features/network/LldpLinksPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
278
web/src/features/network/NetworkAlarmsPage.tsx
Normal file
278
web/src/features/network/NetworkAlarmsPage.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { Button, Input } from "@heroui/react";
|
||||
import { useEffect, 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 { 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";
|
||||
import { NmStatusChip, severityChipColor } from "../../components/NmChips";
|
||||
|
||||
/** Current UME alarms query view for Network Management. */
|
||||
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 [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 currentQuery = useQuery({
|
||||
queryKey: queryKeys.umeCurrentAlarms(
|
||||
curSeverity,
|
||||
curCleared,
|
||||
debouncedHost,
|
||||
debouncedKeyword,
|
||||
curPage,
|
||||
curPageSize,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchUmeCurrentAlarms({
|
||||
severity: curSeverity,
|
||||
isCleared: curCleared,
|
||||
hostName: debouncedHost,
|
||||
keyword: debouncedKeyword,
|
||||
page: curPage,
|
||||
pageSize: curPageSize,
|
||||
}),
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
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 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 nm-page-panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("ume.alarms.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exporting || curTotal === 0}
|
||||
onPress={() => void exportCsv()}
|
||||
>
|
||||
{exporting ? t("common.exporting") : t("common.exportCsv")}
|
||||
</Button>
|
||||
</div>
|
||||
</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>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((x) => (
|
||||
<tr key={x.alarm_key}>
|
||||
<td className="pt-list-time">{formatSystemTime(x.time_created)}</td>
|
||||
<td>
|
||||
<NmStatusChip color={severityChipColor(x.perceived_severity)}>
|
||||
{x.perceived_severity || "—"}
|
||||
</NmStatusChip>
|
||||
</td>
|
||||
<td className="pt-list-num">{x.ne_id}</td>
|
||||
<td>
|
||||
{(x.host_name || "").trim() ? (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="link-btn pt-list-task-name"
|
||||
onPress={() => {
|
||||
setCurHostName(x.host_name || "");
|
||||
setCurKeyword("");
|
||||
setCurPage(1);
|
||||
}}
|
||||
aria-label={t("ume.alarms.filterByHost")}
|
||||
>
|
||||
{x.host_name}
|
||||
</Button>
|
||||
) : (
|
||||
<span className="muted" title={t("ume.alarms.noHostName")}>
|
||||
{t("common.empty")}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{x.ne_type ?? ""}</td>
|
||||
<td>{x.native_probable_cause}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ListPager
|
||||
page={curPage}
|
||||
pages={curPages}
|
||||
total={curTotal}
|
||||
pageSize={curPageSize}
|
||||
pageSizeOptions={[10, 20, 50, 100, 200]}
|
||||
onPageChange={setCurPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setCurPageSize(size);
|
||||
setCurPage(1);
|
||||
}}
|
||||
disabled={currentQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
395
web/src/features/network/NetworkConfigsPage.tsx
Normal file
395
web/src/features/network/NetworkConfigsPage.tsx
Normal file
|
|
@ -0,0 +1,395 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useEffect, 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 { 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 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";
|
||||
import { NmStatusChip, sourceChipColor } from "../../components/NmChips";
|
||||
|
||||
function fmtBytes(n: number): string {
|
||||
if (!n) return "0 B";
|
||||
if (n < 1024) return `${n} B`;
|
||||
if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`;
|
||||
return `${(n / (1024 * 1024)).toFixed(2)} MB`;
|
||||
}
|
||||
|
||||
export function NetworkConfigsPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
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 [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, debouncedKeyword, source, debouncedVendor, pageSize),
|
||||
queryFn: () =>
|
||||
fetchNeConfigSnapshots({
|
||||
page,
|
||||
pageSize,
|
||||
keyword: debouncedKeyword,
|
||||
source,
|
||||
vendor: debouncedVendor,
|
||||
}),
|
||||
staleTime: 5000,
|
||||
});
|
||||
|
||||
const detailQuery = useQuery({
|
||||
queryKey: queryKeys.networkConfigDetail(selected?.source || "", selected?.id || ""),
|
||||
queryFn: () => fetchNeConfigSnapshotDetail(selected!.source, selected!.id, "both"),
|
||||
enabled: Boolean(selected),
|
||||
staleTime: 10000,
|
||||
});
|
||||
|
||||
const items = listQuery.data?.items ?? [];
|
||||
const total = Number(listQuery.data?.total || 0);
|
||||
const pages = pageCount(total, pageSize);
|
||||
const detail = detailQuery.data;
|
||||
const showAlt = Boolean(detail?.has_alt);
|
||||
const hasFilters = Boolean(keyword || source || vendor);
|
||||
|
||||
const exportConfig = async (
|
||||
src: string,
|
||||
id: string,
|
||||
field: "primary" | "alt" | "both",
|
||||
) => {
|
||||
const key = `${src}:${id}:${field}`;
|
||||
setExporting(key);
|
||||
try {
|
||||
await downloadNeConfigSnapshot(src, id, field);
|
||||
showOk(t("networkConfigs.exportOk"));
|
||||
} catch (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 nm-page-panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("networkConfigs.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exportingList || total === 0}
|
||||
onPress={() => void exportListCsv()}
|
||||
>
|
||||
{exportingList ? t("common.exporting") : t("common.exportCsv")}
|
||||
</Button>
|
||||
</div>
|
||||
</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>{t("portTraffic.col.actions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((row) => {
|
||||
const exportKey = `${row.source}:${row.target_id}:list`;
|
||||
return (
|
||||
<tr key={`${row.source}:${row.target_id}`}>
|
||||
<td className="pt-list-task-name">{row.ne_name || row.target_id}</td>
|
||||
<td className="pt-list-num">{row.ne_ip}</td>
|
||||
<td>{row.vendor || "—"}</td>
|
||||
<td>
|
||||
<NmStatusChip color={sourceChipColor(row.source)}>{row.source}</NmStatusChip>
|
||||
</td>
|
||||
<td className="pt-list-num">{fmtBytes(row.plain_size)}</td>
|
||||
<td className="pt-list-time">
|
||||
{row.collected_at ? formatSystemTime(row.collected_at) : "—"}
|
||||
</td>
|
||||
<td>
|
||||
<div className="btn-row pt-list-actions table-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
setSelected({ source: row.source, id: row.target_id });
|
||||
setTab("primary");
|
||||
}}
|
||||
>
|
||||
{t("networkConfigs.view")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
isDisabled={
|
||||
exporting === exportKey ||
|
||||
exporting.startsWith(`${row.source}:${row.target_id}:`)
|
||||
}
|
||||
onPress={() =>
|
||||
void exportConfig(
|
||||
row.source,
|
||||
row.target_id,
|
||||
row.has_alt ? "both" : "primary",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("networkConfigs.export")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => {
|
||||
const path =
|
||||
row.source === "ume"
|
||||
? `/webcrt?ne_id=${encodeURIComponent(row.target_id)}&source=ume`
|
||||
: `/webcrt?ne_id=${encodeURIComponent(row.target_id)}`;
|
||||
openNewModuleWindow({ moduleId: "webcrt", path });
|
||||
}}
|
||||
>
|
||||
WebCRT
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ListPager
|
||||
page={page}
|
||||
pages={pages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={[10, 20, 50, 100]}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={listQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AppModalShell
|
||||
open={Boolean(selected)}
|
||||
onClose={() => setSelected(null)}
|
||||
size="lg"
|
||||
className="nm-config-modal"
|
||||
>
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{detail?.ne_name || selected?.id || t("networkConfigs.view")}</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
{selected ? (
|
||||
<p className="muted">
|
||||
{selected.source} · {detail?.ne_ip || "—"} · {detail?.vendor || "—"}
|
||||
</p>
|
||||
) : null}
|
||||
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
|
||||
{showAlt ? (
|
||||
<div className="btn-row nm-config-modal__tabs">
|
||||
<Button
|
||||
size="sm"
|
||||
variant={tab === "primary" ? "primary" : "secondary"}
|
||||
onPress={() => setTab("primary")}
|
||||
>
|
||||
{t("networkConfigs.tabSet")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant={tab === "alt" ? "primary" : "secondary"}
|
||||
onPress={() => setTab("alt")}
|
||||
>
|
||||
{t("networkConfigs.tabHier")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
<textarea
|
||||
className="nm-config-modal__body"
|
||||
readOnly
|
||||
spellCheck={false}
|
||||
value={tab === "alt" ? detail?.config_alt_text || "" : detail?.config_text || ""}
|
||||
/>
|
||||
<p className="muted nm-config-modal__meta">
|
||||
SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "}
|
||||
{fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)}
|
||||
</p>
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
{selected ? (
|
||||
<>
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={Boolean(exporting)}
|
||||
onPress={() =>
|
||||
void exportConfig(
|
||||
selected.source,
|
||||
selected.id,
|
||||
showAlt ? (tab === "alt" ? "alt" : "primary") : "primary",
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("networkConfigs.export")}
|
||||
</Button>
|
||||
{showAlt ? (
|
||||
<Button
|
||||
variant="secondary"
|
||||
isDisabled={Boolean(exporting)}
|
||||
onPress={() => void exportConfig(selected.source, selected.id, "both")}
|
||||
>
|
||||
{t("networkConfigs.exportBoth")}
|
||||
</Button>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<Button variant="tertiary" onPress={() => setSelected(null)}>
|
||||
{t("networkConfigs.close")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
211
web/src/features/network/NetworkDevicesPage.tsx
Normal file
211
web/src/features/network/NetworkDevicesPage.tsx
Normal file
|
|
@ -0,0 +1,211 @@
|
|||
import { Button, Input } 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 { 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";
|
||||
import { openNewModuleWindow } from "../../utils/moduleWindows";
|
||||
import { connectChipColor, NmStatusChip, sourceChipColor } from "../../components/NmChips";
|
||||
|
||||
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(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 }),
|
||||
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 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 nm-page-panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("networkDevices.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={exporting || total === 0}
|
||||
onPress={() => void exportCsv()}
|
||||
>
|
||||
{exporting ? t("common.exporting") : t("common.exportCsv")}
|
||||
</Button>
|
||||
</div>
|
||||
</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.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>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ListPager
|
||||
page={page}
|
||||
pages={pages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={[10, 20, 50, 100, 200]}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={listQuery.isLoading}
|
||||
/>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
128
web/src/features/network/NetworkLayout.tsx
Normal file
128
web/src/features/network/NetworkLayout.tsx
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import { Accordion } from "@heroui/react";
|
||||
import type { Key } from "@react-types/shared";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||
import { NETWORK_NAV, type NetworkNavGroupId } from "../../config/networkNav";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { prefetchCutoverPages } from "../cutover/lib/cutoverPrefetch";
|
||||
|
||||
function groupContainsPath(groupId: NetworkNavGroupId, pathname: string): boolean {
|
||||
const group = NETWORK_NAV.find((g) => g.id === groupId);
|
||||
if (!group) return false;
|
||||
return group.items.some(
|
||||
(item) => pathname === item.path || pathname.startsWith(`${item.path}/`),
|
||||
);
|
||||
}
|
||||
|
||||
function groupsForPath(pathname: string): Set<Key> {
|
||||
const keys = new Set<Key>();
|
||||
for (const g of NETWORK_NAV) {
|
||||
if (groupContainsPath(g.id, pathname)) keys.add(g.id);
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
|
||||
export function NetworkLayout() {
|
||||
const { t } = useI18n();
|
||||
const { pathname } = useLocation();
|
||||
const [expandedKeys, setExpandedKeys] = useState<Set<Key>>(() => groupsForPath(pathname));
|
||||
|
||||
useEffect(() => {
|
||||
setExpandedKeys((prev) => {
|
||||
const routeKeys = groupsForPath(pathname);
|
||||
if ([...routeKeys].every((k) => prev.has(k))) return prev;
|
||||
const next = new Set(prev);
|
||||
for (const k of routeKeys) next.add(k);
|
||||
return next;
|
||||
});
|
||||
}, [pathname]);
|
||||
|
||||
// Warm cutover page chunks as soon as that nav group is open / in use.
|
||||
useEffect(() => {
|
||||
if (pathname.startsWith("/network/cutover") || expandedKeys.has("cutoverCompare")) {
|
||||
prefetchCutoverPages();
|
||||
}
|
||||
}, [pathname, expandedKeys]);
|
||||
|
||||
const activeGroupIds = useMemo(() => {
|
||||
const ids = new Set<NetworkNavGroupId>();
|
||||
for (const g of NETWORK_NAV) {
|
||||
if (groupContainsPath(g.id, pathname)) ids.add(g.id);
|
||||
}
|
||||
return ids;
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<div className="network-shell">
|
||||
<aside className="network-nav" aria-label={t("network.title")}>
|
||||
<nav className="network-nav__scroll">
|
||||
<Accordion
|
||||
className="network-nav__accordion"
|
||||
hideSeparator
|
||||
allowsMultipleExpanded
|
||||
expandedKeys={expandedKeys}
|
||||
onExpandedChange={(keys) => {
|
||||
setExpandedKeys(keys as Set<Key>);
|
||||
if ((keys as Set<Key>).has("cutoverCompare")) {
|
||||
prefetchCutoverPages();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{NETWORK_NAV.map((group) => {
|
||||
const activeGroup = activeGroupIds.has(group.id);
|
||||
return (
|
||||
<Accordion.Item
|
||||
key={group.id}
|
||||
id={group.id}
|
||||
className={`network-nav__group${activeGroup ? " is-active-group" : ""}`}
|
||||
>
|
||||
<Accordion.Heading>
|
||||
<Accordion.Trigger
|
||||
className="network-nav__group-toggle"
|
||||
onHoverStart={
|
||||
group.id === "cutoverCompare" ? () => prefetchCutoverPages() : undefined
|
||||
}
|
||||
>
|
||||
<span className="network-nav__group-label">{t(group.labelKey)}</span>
|
||||
<Accordion.Indicator className="network-nav__chevron" />
|
||||
</Accordion.Trigger>
|
||||
</Accordion.Heading>
|
||||
<Accordion.Panel>
|
||||
<Accordion.Body className="network-nav__panel-body">
|
||||
<ul className="network-nav__list">
|
||||
{group.items.map((item) => (
|
||||
<li key={item.id}>
|
||||
<NavLink
|
||||
to={item.path}
|
||||
className={({ isActive }) =>
|
||||
`network-nav__link${isActive ? " is-active" : ""}`
|
||||
}
|
||||
end={
|
||||
item.path === "/network/devices" ||
|
||||
item.path === "/network/tasks/port-traffic"
|
||||
}
|
||||
onMouseEnter={
|
||||
group.id === "cutoverCompare"
|
||||
? () => prefetchCutoverPages()
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</NavLink>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</Accordion.Body>
|
||||
</Accordion.Panel>
|
||||
</Accordion.Item>
|
||||
);
|
||||
})}
|
||||
</Accordion>
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="network-main">
|
||||
<Outlet />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
13
web/src/features/network/NetworkPlaceholderPage.tsx
Normal file
13
web/src/features/network/NetworkPlaceholderPage.tsx
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
type Props = {
|
||||
kind?: string;
|
||||
};
|
||||
|
||||
/** Generic placeholder for unfinished network sub-pages. */
|
||||
export function NetworkPlaceholderPage(_props: Props) {
|
||||
return (
|
||||
<section className="panel network-placeholder">
|
||||
<h2>Coming soon</h2>
|
||||
<p className="muted">This capability is not available yet.</p>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
592
web/src/features/network/TopologyClassifyPage.tsx
Normal file
592
web/src/features/network/TopologyClassifyPage.tsx
Normal file
|
|
@ -0,0 +1,592 @@
|
|||
import { Button, Input } from "@heroui/react";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import {
|
||||
bulkTagFabricNodes,
|
||||
deleteFabricNode,
|
||||
deleteFabricNodes,
|
||||
fetchFabricNodes,
|
||||
fetchTopologyTree,
|
||||
matchFabricNodes,
|
||||
patchFabricNodeTags,
|
||||
} from "../../services/api";
|
||||
import { ListPager } from "../../components/ListPager";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import { openOrFocusModule } from "../../utils/moduleWindows";
|
||||
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
|
||||
import { pageCount } from "../../utils/display";
|
||||
|
||||
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
|
||||
|
||||
function flattenRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] {
|
||||
if (!root) return [];
|
||||
return (root.children || []).filter((c) => String(c.kind) === "region");
|
||||
}
|
||||
|
||||
/** Prefer API flag; fall back so UI still works if backend is stale. */
|
||||
function isFabricNodeDeletable(n: FabricNodeSearchHit): boolean {
|
||||
if (typeof n.deletable === "boolean") return n.deletable;
|
||||
const status = String(n.link_status || "").toLowerCase();
|
||||
if (status === "ume" || status === "both") return false;
|
||||
if (status === "orphaned") return true;
|
||||
if (!String(n.managed_ne_id || "").trim()) {
|
||||
return !String(n.ume_ne_id || "").trim();
|
||||
}
|
||||
if (n.managed_alive === false) return true;
|
||||
const src = String(n.managed_source || "").toLowerCase();
|
||||
return src === "lldp" || src === "topology" || src === "webcrt";
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
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 {
|
||||
const s = String(source || "").trim().toLowerCase();
|
||||
if (s === "manual") return t("topoClassify.sourceManual");
|
||||
if (s === "ume_sync") return t("topoClassify.sourceUmeSync");
|
||||
if (s === "lldp") return t("topoClassify.sourceLldp");
|
||||
if (s === "topology") return t("topoClassify.sourceTopology");
|
||||
if (s === "webcrt") return t("topoClassify.sourceWebcrt");
|
||||
return source;
|
||||
}
|
||||
|
||||
export function TopologyClassifyPage() {
|
||||
const { t } = useI18n();
|
||||
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);
|
||||
|
||||
const linkLabel = (status: string | undefined) => {
|
||||
switch (String(status || "").toLowerCase()) {
|
||||
case "managed":
|
||||
return t("topoClassify.linkManaged");
|
||||
case "ume":
|
||||
return t("topoClassify.linkUme");
|
||||
case "both":
|
||||
return t("topoClassify.linkBoth");
|
||||
case "orphaned":
|
||||
return t("topoClassify.linkOrphaned");
|
||||
default:
|
||||
return status || "—";
|
||||
}
|
||||
};
|
||||
|
||||
const [regex, setRegex] = useState("");
|
||||
const [matchField, setMatchField] = useState<"name" | "ip" | "name_ip">("name");
|
||||
const [matchedIds, setMatchedIds] = useState<string[]>([]);
|
||||
const [matchTotal, setMatchTotal] = useState(0);
|
||||
const [assignLevel, setAssignLevel] = useState("1");
|
||||
const [assignRegion, setAssignRegion] = useState("");
|
||||
const [assignWhat, setAssignWhat] = useState<"level" | "region" | "both">("level");
|
||||
|
||||
const [selected, setSelected] = useState<Record<string, boolean>>({});
|
||||
const [editingId, setEditingId] = useState("");
|
||||
const [editLevel, setEditLevel] = useState("");
|
||||
const [editRegion, setEditRegion] = useState("");
|
||||
|
||||
const treeQuery = useQuery({
|
||||
queryKey: queryKeys.topologyTree,
|
||||
queryFn: fetchTopologyTree,
|
||||
});
|
||||
const regions = useMemo(() => flattenRegions(treeQuery.data?.root), [treeQuery.data?.root]);
|
||||
const regionName = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const r of regions) m[r.id] = r.name;
|
||||
return m;
|
||||
}, [regions]);
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
|
||||
const invalidateList = async () => {
|
||||
await queryClient.invalidateQueries({ queryKey: ["fabricNodeInventory"] });
|
||||
};
|
||||
|
||||
const matchMut = useMutation({
|
||||
mutationFn: () =>
|
||||
matchFabricNodes({ pattern: regex, match_field: matchField, sample_limit: 80 }),
|
||||
onSuccess: (out) => {
|
||||
setMatchedIds(out.fabric_node_ids || []);
|
||||
setMatchTotal(out.total_matched || 0);
|
||||
const next: Record<string, boolean> = {};
|
||||
for (const id of out.fabric_node_ids || []) next[id] = true;
|
||||
setSelected(next);
|
||||
showOk(t("topoClassify.matchOk").replace("{{count}}", String(out.total_matched)));
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const bulkMut = useMutation({
|
||||
mutationFn: () => {
|
||||
const ids = Object.keys(selected).filter((k) => selected[k]);
|
||||
const body: Parameters<typeof bulkTagFabricNodes>[0] = {
|
||||
fabric_node_ids: ids.length ? ids : matchedIds,
|
||||
dry_run: false,
|
||||
};
|
||||
if (assignWhat === "level" || assignWhat === "both") {
|
||||
body.level = parseLevelInput(assignLevel);
|
||||
}
|
||||
if (assignWhat === "region" || assignWhat === "both") {
|
||||
body.region_folder_id = assignRegion || "";
|
||||
}
|
||||
return bulkTagFabricNodes(body);
|
||||
},
|
||||
onSuccess: async (out) => {
|
||||
showOk(t("topoClassify.bulkOk").replace("{{count}}", String(out.updated)));
|
||||
setMatchedIds([]);
|
||||
setMatchTotal(0);
|
||||
setSelected({});
|
||||
await invalidateList();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const patchMut = useMutation({
|
||||
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("");
|
||||
await invalidateList();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const deleteOneMut = useMutation({
|
||||
mutationFn: (id: string) => deleteFabricNode(id),
|
||||
onSuccess: async (out) => {
|
||||
showOk(t("topoClassify.deleteOk").replace("{{count}}", String(out.deleted)));
|
||||
setSelected({});
|
||||
await invalidateList();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const deleteBulkMut = useMutation({
|
||||
mutationFn: (ids: string[]) => deleteFabricNodes(ids),
|
||||
onSuccess: async (out) => {
|
||||
showOk(t("topoClassify.deleteOk").replace("{{count}}", String(out.deleted)));
|
||||
setSelected({});
|
||||
await invalidateList();
|
||||
},
|
||||
onError: (err) => showError(String(err)),
|
||||
});
|
||||
|
||||
const items = listQuery.data?.items || [];
|
||||
const deletableSelected = items
|
||||
.filter((n) => selected[n.id] && isFabricNodeDeletable(n))
|
||||
.map((n) => n.id);
|
||||
const total = listQuery.data?.total || 0;
|
||||
const pages = pageCount(total, pageSize);
|
||||
const selectedCount = Object.values(selected).filter(Boolean).length;
|
||||
const highlight = useMemo(() => new Set(matchedIds), [matchedIds]);
|
||||
|
||||
const startEdit = (n: FabricNodeSearchHit) => {
|
||||
setEditingId(n.id);
|
||||
setEditLevel(n.level == null ? "" : formatLevel(n.level));
|
||||
setEditRegion(n.region_folder_id || "");
|
||||
};
|
||||
|
||||
const toggleRow = (id: string) => {
|
||||
setSelected((s) => ({ ...s, [id]: !s[id] }));
|
||||
};
|
||||
|
||||
const togglePage = (on: boolean) => {
|
||||
setSelected((s) => {
|
||||
const next = { ...s };
|
||||
for (const n of items) next[n.id] = on;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="panel topo-classify nm-page-panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("topoClassify.title")}</h2>
|
||||
<div className="btn-row">
|
||||
<Button
|
||||
variant="primary"
|
||||
onPress={() => {
|
||||
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>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<div className="topo-classify__regex-bar">
|
||||
<strong>{t("topoClassify.regexTitle")}</strong>
|
||||
<Input
|
||||
className="topo-classify__regex-input"
|
||||
placeholder={t("topoClassify.patternPh")}
|
||||
value={regex}
|
||||
onChange={(e) => setRegex(e.target.value)}
|
||||
/>
|
||||
<select className="input"
|
||||
value={matchField}
|
||||
onChange={(e) => setMatchField(e.target.value as "name" | "ip" | "name_ip")}
|
||||
>
|
||||
<option value="name">{t("topoClassify.fieldName")}</option>
|
||||
<option value="ip">IP</option>
|
||||
<option value="name_ip">{t("topoClassify.fieldNameIp")}</option>
|
||||
</select>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={!regex.trim() || matchMut.isPending}
|
||||
onPress={() => matchMut.mutate()}
|
||||
>
|
||||
{t("topoClassify.findMatches")}
|
||||
</Button>
|
||||
{matchTotal > 0 ? (
|
||||
<span className="panel__hint">
|
||||
{t("topoClassify.matchHint").replace("{{count}}", String(matchTotal))}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="topo-classify__assign-bar">
|
||||
<select className="input"
|
||||
value={assignWhat}
|
||||
onChange={(e) => setAssignWhat(e.target.value as "level" | "region" | "both")}
|
||||
>
|
||||
<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={assignLevel} onChange={(e) => setAssignLevel(e.target.value)}>
|
||||
{LEVEL_PRESETS.map((r) => (
|
||||
<option key={r.value} value={r.value}>
|
||||
{levelPresetLabel(t, r.value)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
{assignWhat !== "level" ? (
|
||||
<select className="input" value={assignRegion} onChange={(e) => setAssignRegion(e.target.value)}>
|
||||
<option value="">{t("topoClassify.clearRegion")}</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={bulkMut.isPending || (selectedCount === 0 && matchedIds.length === 0)}
|
||||
onPress={() => {
|
||||
const n = selectedCount || matchedIds.length;
|
||||
const msg = t("topoClassify.bulkConfirm").replace("{{count}}", String(n));
|
||||
if (window.confirm(msg)) bulkMut.mutate();
|
||||
}}
|
||||
>
|
||||
{t("topoClassify.confirmAssign")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={deleteBulkMut.isPending || deletableSelected.length === 0}
|
||||
onPress={() => {
|
||||
const msg = t("topoClassify.deleteConfirm").replace(
|
||||
"{{count}}",
|
||||
String(deletableSelected.length),
|
||||
);
|
||||
if (window.confirm(msg)) deleteBulkMut.mutate(deletableSelected);
|
||||
}}
|
||||
>
|
||||
{t("topoClassify.deleteSelected").replace(
|
||||
"{{count}}",
|
||||
String(deletableSelected.length),
|
||||
)}
|
||||
</Button>
|
||||
<span className="topo-classify__help" tabIndex={0} aria-label={t("topoClassify.deleteHint")}>
|
||||
?
|
||||
<span className="topo-classify__help-tip" role="tooltip">
|
||||
{t("topoClassify.deleteHint")}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<table className="data-table topo-classify__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={items.length > 0 && items.every((n) => selected[n.id])}
|
||||
onChange={(e) => togglePage(e.target.checked)}
|
||||
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>{t("topoClassify.colActions")}</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((n) => {
|
||||
const editing = editingId === n.id;
|
||||
return (
|
||||
<tr
|
||||
key={n.id}
|
||||
className={highlight.has(n.id) || selected[n.id] ? "is-match" : undefined}
|
||||
>
|
||||
<td>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={Boolean(selected[n.id])}
|
||||
onChange={() => toggleRow(n.id)}
|
||||
/>
|
||||
</td>
|
||||
<td>{n.name || n.id}</td>
|
||||
<td>{n.ip}</td>
|
||||
<td>
|
||||
{linkLabel(n.link_status)}
|
||||
{n.managed_source ? ` · ${managedSourceLabel(t, n.managed_source)}` : ""}
|
||||
</td>
|
||||
<td>
|
||||
{editing ? (
|
||||
<Input
|
||||
value={editLevel}
|
||||
placeholder="1 / 1.1 / 2.1"
|
||||
onChange={(e) => setEditLevel(e.target.value)}
|
||||
/>
|
||||
) : (
|
||||
formatLevel(n.level)
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editing ? (
|
||||
<select className="input" value={editRegion} onChange={(e) => setEditRegion(e.target.value)}>
|
||||
<option value="">-</option>
|
||||
{regions.map((r) => (
|
||||
<option key={r.id} value={r.id}>
|
||||
{r.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : (
|
||||
(n.region_folder_id && regionName[n.region_folder_id]) ||
|
||||
n.region_folder_id ||
|
||||
"-"
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{editing ? (
|
||||
<div className="topo-classify__row-actions">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
isDisabled={patchMut.isPending}
|
||||
onPress={() => patchMut.mutate()}
|
||||
>
|
||||
{t("topoClassify.save")}
|
||||
</Button>
|
||||
<Button size="sm" variant="ghost" onPress={() => setEditingId("")}>
|
||||
{t("topoClassify.cancel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="topo-classify__row-actions">
|
||||
<Button size="sm" variant="ghost" onPress={() => startEdit(n)}>
|
||||
{t("topoClassify.edit")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={deleteOneMut.isPending || !isFabricNodeDeletable(n)}
|
||||
onPress={() => {
|
||||
if (!isFabricNodeDeletable(n)) return;
|
||||
const msg = t("topoClassify.deleteConfirm").replace(
|
||||
"{{count}}",
|
||||
"1",
|
||||
);
|
||||
if (window.confirm(msg)) deleteOneMut.mutate(n.id);
|
||||
}}
|
||||
>
|
||||
{t("topoClassify.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})}
|
||||
{items.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="panel__hint">
|
||||
{listQuery.isLoading ? t("topoClassify.loading") : t("topoClassify.empty")}
|
||||
</td>
|
||||
</tr>
|
||||
) : null}
|
||||
</tbody>
|
||||
</table>
|
||||
<ListPager
|
||||
page={page}
|
||||
pages={pages}
|
||||
total={total}
|
||||
pageSize={pageSize}
|
||||
pageSizeOptions={PAGE_SIZE_OPTIONS}
|
||||
onPageChange={setPage}
|
||||
onPageSizeChange={(size) => {
|
||||
setPageSize(size);
|
||||
setPage(1);
|
||||
}}
|
||||
disabled={listQuery.isLoading}
|
||||
/>
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue