mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Refresh left-nav/main chrome, unify list status chips across network pages, and merge UME token with alarm subscription into one top card. Co-authored-by: Cursor <cursoragent@cursor.com>
592 lines
21 KiB
TypeScript
592 lines
21 KiB
TypeScript
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>
|
|
);
|
|
}
|