Polish network shell and UME with HeroUI chips and denser layouts.

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>
This commit is contained in:
oliver 2026-09-16 00:13:24 +08:00
parent 0a4561f385
commit 41fd5b62a4
13 changed files with 927 additions and 589 deletions

View file

@ -33,6 +33,12 @@ import type {
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
import {
edgeChipColor,
jobChipColor,
NmStatusChip,
sourceChipColor,
} from "./nmChips";
const POLL_MS = 2500;
const TARGET_PAGE_SIZE = 10;
@ -553,7 +559,7 @@ export function LldpLinksPage() {
};
return (
<section className="panel">
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("lldpLinks.title")}</h2>
<div className="btn-row">
@ -776,7 +782,9 @@ export function LldpLinksPage() {
onChange={() => toggleTarget(row)}
/>
</td>
<td>{source}</td>
<td>
<NmStatusChip color={sourceChipColor(source)}>{source}</NmStatusChip>
</td>
<td>{row.name}</td>
<td>{row.ip_address}</td>
<td>{row.vendor || "-"}</td>
@ -880,7 +888,9 @@ export function LldpLinksPage() {
const st = e.status === "stale" ? "missing" : e.status;
return (
<tr key={e.id}>
<td>{lldpEdgeStatusLabel(t, st)}</td>
<td>
<NmStatusChip color={edgeChipColor(st)}>{lldpEdgeStatusLabel(t, st)}</NmStatusChip>
</td>
<td>
<div>{aLabel}</div>
{e.a_ip && e.a_name ? <div className="muted">{e.a_ip}</div> : null}
@ -891,7 +901,11 @@ export function LldpLinksPage() {
{e.b_ip && e.b_name ? <div className="muted">{e.b_ip}</div> : null}
</td>
<td>{e.b_port || "—"}</td>
<td>{lldpEdgeSourceLabel(t, e.source)}</td>
<td>
<NmStatusChip color={sourceChipColor(e.source)}>
{lldpEdgeSourceLabel(t, e.source)}
</NmStatusChip>
</td>
<td>
{missCount || (st === "missing" ? 1 : 0)}
{replaced ? (
@ -1003,17 +1017,9 @@ export function LldpLinksPage() {
<td>{lldpTriggerLabel(t, job.trigger_mode)}</td>
<td>{lldpScopeLabel(t, job.scope)}</td>
<td>
<span
className={
job.status === "running" || job.status === "pending"
? "pt-list-status--running"
: job.status === "paused"
? "pt-list-status--paused"
: undefined
}
>
<NmStatusChip color={jobChipColor(job.status)}>
{lldpJobStatusLabel(t, job.status)}
</span>
</NmStatusChip>
</td>
<td>
{job.done}/{job.total}
@ -1034,7 +1040,7 @@ export function LldpLinksPage() {
{job.status === "running" || job.status === "pending" ? (
<Button
size="sm"
variant="secondary"
variant="ghost"
isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(job.id)}
>
@ -1044,7 +1050,7 @@ export function LldpLinksPage() {
{job.status === "paused" ? (
<Button
size="sm"
variant="secondary"
variant="ghost"
isDisabled={resumeMut.isPending}
onPress={() => resumeMut.mutate(job.id)}
>
@ -1170,7 +1176,23 @@ export function LldpLinksPage() {
<tr key={it.id}>
<td>{it.ne_name || it.ne_id || "—"}</td>
<td>{it.ne_ip || "—"}</td>
<td>{lldpItemResultLabel(t, it)}</td>
<td>
<NmStatusChip
color={
!it.ok ||
Boolean(it.parser_stub) ||
["parser_stub", "empty_cli_output", "vendor_or_device_type_required"].includes(
String(it.error || "").trim(),
)
? "danger"
: (it.unmatched_count ?? (it.unmatched?.length || 0)) > 0
? "warning"
: "success"
}
>
{lldpItemResultLabel(t, it)}
</NmStatusChip>
</td>
<td>{it.neighbors}</td>
<td>
+{it.edges_added} / ~{it.edges_updated}

View file

@ -13,16 +13,7 @@ import type { UmeAlarmItem } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
function severityClass(sev: string | null | undefined): string {
const s = String(sev || "").trim().toLowerCase();
if (s.includes("critical")) return "pt-list-status--critical";
if (s.includes("major")) return "pt-list-status--major";
if (s.includes("minor")) return "pt-list-status--minor";
if (s.includes("warning") || s.includes("warn")) return "pt-list-status--warning";
if (s.includes("info") || s.includes("indeterminate")) return "pt-list-status--info";
return "pt-list-status--unknown";
}
import { NmStatusChip, severityChipColor } from "./nmChips";
/** Current UME alarms query view for Network Management. */
export function NetworkAlarmsPage() {
@ -133,7 +124,7 @@ export function NetworkAlarmsPage() {
};
return (
<section className="panel">
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2>
<div className="btn-row">
@ -233,9 +224,9 @@ export function NetworkAlarmsPage() {
<tr key={x.alarm_key}>
<td className="pt-list-time">{formatSystemTime(x.time_created)}</td>
<td>
<span className={`pt-list-status ${severityClass(x.perceived_severity)}`}>
<NmStatusChip color={severityChipColor(x.perceived_severity)}>
{x.perceived_severity || "—"}
</span>
</NmStatusChip>
</td>
<td className="pt-list-num">{x.ne_id}</td>
<td>

View file

@ -20,6 +20,7 @@ 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 "./nmChips";
function fmtBytes(n: number): string {
if (!n) return "0 B";
@ -149,7 +150,7 @@ export function NetworkConfigsPage() {
};
return (
<section className="panel">
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("networkConfigs.title")}</h2>
<div className="btn-row">
@ -237,7 +238,9 @@ export function NetworkConfigsPage() {
<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>{row.source}</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) : "—"}
@ -246,7 +249,7 @@ export function NetworkConfigsPage() {
<div className="btn-row pt-list-actions table-actions">
<Button
size="sm"
variant="secondary"
variant="ghost"
onPress={() => {
setSelected({ source: row.source, id: row.target_id });
setTab("primary");
@ -256,7 +259,7 @@ export function NetworkConfigsPage() {
</Button>
<Button
size="sm"
variant="secondary"
variant="ghost"
isDisabled={
exporting === exportKey ||
exporting.startsWith(`${row.source}:${row.target_id}:`)
@ -273,7 +276,7 @@ export function NetworkConfigsPage() {
</Button>
<Button
size="sm"
variant="secondary"
variant="ghost"
onPress={() => {
const path =
row.source === "ume"

View file

@ -12,16 +12,7 @@ import type { CliTargetItem } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { openNewModuleWindow } from "../../utils/moduleWindows";
function connectStatusClass(status: string | null | undefined): string {
const s = String(status || "").trim().toLowerCase();
if (!s || s === "-" || s === "unknown") return "pt-list-status--unknown";
if (s.includes("fail") || s.includes("down") || s.includes("error")) return "pt-list-status--down";
if (s.includes("ok") || s.includes("up") || s.includes("connected") || s.includes("success")) {
return "pt-list-status--ok";
}
return "pt-list-status--other";
}
import { connectChipColor, NmStatusChip, sourceChipColor } from "./nmChips";
function openWebcrt(row: CliTargetItem) {
const path =
@ -89,7 +80,7 @@ export function NetworkDevicesPage() {
};
return (
<section className="panel">
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("networkDevices.title")}</h2>
<div className="btn-row">
@ -163,24 +154,26 @@ export function NetworkDevicesPage() {
<tbody>
{items.map((row) => (
<tr key={`${row.source}:${row.id}`}>
<td>{row.source}</td>
<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>
<span className={`pt-list-status ${connectStatusClass(row.connect_status)}`}>
<NmStatusChip color={connectChipColor(row.connect_status)}>
{row.connect_status || "—"}
</span>
</NmStatusChip>
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<Button size="sm" variant="secondary" onPress={() => openWebcrt(row)}>
<Button size="sm" variant="ghost" onPress={() => openWebcrt(row)}>
WebCRT
</Button>
<Button
size="sm"
variant="secondary"
variant="ghost"
onPress={() =>
openNewModuleWindow({
moduleId: "network",

View file

@ -1,4 +1,5 @@
import { useEffect, useState } from "react";
import { Accordion } from "@heroui/react";
import { useEffect, useMemo, useState, type Key } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { NETWORK_NAV, type NetworkNavGroupId } from "../../config/networkNav";
import { useI18n } from "../../i18n";
@ -11,76 +12,89 @@ function groupContainsPath(groupId: NetworkNavGroupId, pathname: string): boolea
);
}
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 [openGroups, setOpenGroups] = useState<Record<NetworkNavGroupId, boolean>>(() => {
const init = {} as Record<NetworkNavGroupId, boolean>;
for (const g of NETWORK_NAV) {
// Default collapsed; only the group owning the current route starts open.
init[g.id] = groupContainsPath(g.id, pathname);
}
return init;
});
const [expandedKeys, setExpandedKeys] = useState<Set<Key>>(() => groupsForPath(pathname));
useEffect(() => {
for (const g of NETWORK_NAV) {
if (groupContainsPath(g.id, pathname)) {
setOpenGroups((prev) => (prev[g.id] ? prev : { ...prev, [g.id]: true }));
}
}
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]);
const toggleGroup = (id: NetworkNavGroupId) => {
setOpenGroups((prev) => ({ ...prev, [id]: !prev[id] }));
};
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")}>
<div className="network-nav__brand">{t("network.title")}</div>
<nav className="network-nav__scroll">
{NETWORK_NAV.map((group) => {
const open = Boolean(openGroups[group.id]);
const activeGroup = groupContainsPath(group.id, pathname);
return (
<div
key={group.id}
className={`network-nav__group${open ? " is-open" : ""}${activeGroup ? " is-active-group" : ""}`}
>
<button
type="button"
className="network-nav__group-toggle"
aria-expanded={open}
onClick={() => toggleGroup(group.id)}
<Accordion
className="network-nav__accordion"
hideSeparator
allowsMultipleExpanded
expandedKeys={expandedKeys}
onExpandedChange={setExpandedKeys}
>
{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" : ""}`}
>
<span className="network-nav__chevron" aria-hidden>
{open ? "▾" : "▸"}
</span>
<span className="network-nav__group-label">{t(group.labelKey)}</span>
</button>
{open ? (
<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"
}
>
{t(item.labelKey)}
</NavLink>
</li>
))}
</ul>
) : null}
</div>
);
})}
<Accordion.Heading>
<Accordion.Trigger className="network-nav__group-toggle">
<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"
}
>
{t(item.labelKey)}
</NavLink>
</li>
))}
</ul>
</Accordion.Body>
</Accordion.Panel>
</Accordion.Item>
);
})}
</Accordion>
</nav>
</aside>
<div className="network-main">

View file

@ -49,7 +49,7 @@ export function PortTrafficBoardListPage() {
const boards = boardsQuery.data?.items || [];
return (
<section className="panel">
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("portTraffic.wallTitle")}</h2>
<div className="btn-row">
@ -113,7 +113,7 @@ export function PortTrafficBoardListPage() {
<div className="btn-row table-actions">
<Button
size="sm"
variant="primary"
variant="ghost"
onPress={() => openPortTrafficBoardWindow(b.id)}
>
{t("portTraffic.boardOpen")}

View file

@ -36,6 +36,7 @@ import type {
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
import { jobChipColor, NmStatusChip } from "./nmChips";
const POLL_MS = 5000;
const TARGET_PAGE_SIZE = 10;
@ -695,7 +696,7 @@ export function PortTrafficPage() {
};
return (
<section className="panel">
<section className="panel nm-page-panel">
<div className="panel__toolbar">
<h2>{t("portTraffic.title")}</h2>
<div className="btn-row">
@ -819,7 +820,6 @@ export function PortTrafficPage() {
</thead>
<tbody>
{devices.map((row) => {
const tone = statusTone(row.status);
return (
<tr key={row.id}>
<td>
@ -831,14 +831,14 @@ export function PortTrafficPage() {
</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>
) : null}
<div className="pt-port-updown">
<NmStatusChip color={jobChipColor(row.status)}>
{statusText(row.status)}
</NmStatusChip>
{row.collect_running ? (
<NmStatusChip color="accent">{t("portTraffic.collecting")}</NmStatusChip>
) : null}
</div>
</td>
<td className="pt-list-num">
{row.active_target_count}/{row.target_count}
@ -852,18 +852,18 @@ export function PortTrafficPage() {
<Button size="sm" variant="primary" onPress={() => openWallList()}>
{t("portTraffic.wall")}
</Button>
<Button size="sm" variant="secondary" onPress={() => openEdit(row)}>
<Button size="sm" variant="ghost" onPress={() => openEdit(row)}>
{t("portTraffic.edit")}
</Button>
{needsNeRebind(row) ? (
<Button size="sm" variant="secondary" onPress={() => openRebind(row)}>
<Button size="sm" variant="ghost" onPress={() => openRebind(row)}>
{t("portTraffic.rebind")}
</Button>
) : null}
{row.status !== "running" ? (
<Button
size="sm"
variant="secondary"
variant="ghost"
isDisabled={startMut.isPending}
onPress={() => startMut.mutate(row.id)}
>
@ -872,7 +872,7 @@ export function PortTrafficPage() {
) : (
<Button
size="sm"
variant="secondary"
variant="ghost"
isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(row.id)}
>
@ -882,7 +882,7 @@ export function PortTrafficPage() {
{row.status !== "stopped" ? (
<Button
size="sm"
variant="secondary"
variant="ghost"
isDisabled={stopMut.isPending}
onPress={() => stopMut.mutate(row.id)}
>
@ -891,7 +891,7 @@ export function PortTrafficPage() {
) : null}
<Button
size="sm"
variant="secondary"
variant="ghost"
className={row.last_error ? "pt-list-log-btn--error" : undefined}
onPress={() => {
setLogDeviceId(row.id);

View file

@ -274,7 +274,7 @@ export function TopologyClassifyPage() {
};
return (
<div className="panel topo-classify">
<div className="panel topo-classify nm-page-panel">
<div className="panel__toolbar">
<h2>{t("topoClassify.title")}</h2>
<div className="btn-row">

View file

@ -0,0 +1,80 @@
import { Chip } from "@heroui/react";
import type { ReactNode } from "react";
export type NmChipColor = "success" | "danger" | "warning" | "default" | "accent";
export function sourceChipColor(source: string | null | undefined): NmChipColor {
const s = String(source || "").trim().toLowerCase();
if (s === "ume") return "accent";
if (s === "managed" || s === "webcrt") return "default";
return "default";
}
/** Connection / health style statuses. */
export function connectChipColor(status: string | null | undefined): NmChipColor {
const s = String(status || "")
.trim()
.toLowerCase();
if (!s || s === "-" || s === "—" || s === "unknown") return "default";
if (s.includes("fail") || s.includes("down") || s.includes("error") || s.includes("critical")) {
return "danger";
}
if (s.includes("ok") || s.includes("up") || s.includes("connected") || s.includes("success") || s.includes("pass")) {
return "success";
}
if (s.includes("warn") || s.includes("degrad") || s.includes("test") || s.includes("major") || s.includes("minor")) {
return "warning";
}
return "accent";
}
/** Job / cycle / task lifecycle statuses. */
export function jobChipColor(status: string | null | undefined): NmChipColor {
const s = String(status || "")
.trim()
.toLowerCase();
if (!s) return "default";
if (s === "running" || s === "pending" || s === "collect") return "success";
if (s === "paused" || s === "draft" || s === "skipped") return "warning";
if (s === "failed" || s === "fail" || s === "error" || s === "stopped") return "danger";
if (s === "success" || s === "completed" || s === "ok" || s === "done") return "success";
return "default";
}
/** Fabric / LLDP edge status (active / missing / stale). */
export function edgeChipColor(status: string | null | undefined): NmChipColor {
const s = String(status || "")
.trim()
.toLowerCase();
if (s === "active" || s === "ok" || s === "up") return "success";
if (s === "missing" || s === "stale" || s === "down") return "warning";
if (s.includes("fail") || s.includes("error")) return "danger";
return "default";
}
/** Alarm perceived severity. */
export function severityChipColor(sev: string | null | undefined): NmChipColor {
const s = String(sev || "")
.trim()
.toLowerCase();
if (s.includes("critical")) return "danger";
if (s.includes("major")) return "warning";
if (s.includes("minor") || s.includes("warning") || s.includes("warn")) return "warning";
if (s.includes("info") || s.includes("indeterminate")) return "accent";
return "default";
}
type NmStatusChipProps = {
color: NmChipColor;
children: ReactNode;
className?: string;
};
/** Soft status/source chip used across Network Management lists. */
export function NmStatusChip({ color, children, className }: NmStatusChipProps) {
return (
<Chip size="sm" variant="soft" color={color} className={className}>
<Chip.Label>{children}</Chip.Label>
</Chip>
);
}