Add Network Management module with collapsible left nav.

Consolidate devices, topology, alarms, WebCRT, and collection tasks under /network; keep legacy path redirects and workbench entry.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-31 09:48:31 +08:00
parent 3bd5d5b7da
commit 2fc47e5135
13 changed files with 595 additions and 182 deletions

View file

@ -33,11 +33,25 @@ src/
|------|------|----------|
| `/` | 工作台 | — |
| `/ume` | UME 同步 | `ume` |
| `/ne` | 网元管理 | `managed-ne` |
| `/collect` | 批量采集 | `collect` |
| `/webcrt` | WebCRT 终端 | `webcrt` |
| `/network` | 网络管理(左栏 + 子路由) | `network` |
| `/network/devices` | 设备列表 | `network` |
| `/network/topology` | 拓扑信息 | `network` |
| `/network/alarms` | 告警信息 | `network` |
| `/network/webcrt` | WebCRT 终端 | `network` |
| `/network/tasks/config-sync` | 采集任务(原批量采集) | `network` |
| `/network/tasks/port-traffic` | 端口流量(占位) | `network` |
| `/ne` | redirect → `/network/devices` | — |
| `/collect` | redirect → `/network/tasks/config-sync` | — |
| `/topology` | redirect → `/network/topology` | — |
| `/webcrt` | redirect → `/network/webcrt`(保留 query) | — |
**新增模块只需改 `config/modules.ts`:**
菜单树见 `config/networkNav.ts`。**新增网络子页:**
1. 在 `networkNav.ts` 增加叶子项
2. 在 `App.tsx` `/network` 下增加子 `<Route>`
3. 补充 i18n `network.*`
**新增工作台模块仍改 `config/modules.ts`:**
1. 在 `MODULES` 增加一项(`moduleId`、`path`、`section`、i18n key、`iconTone`)
2. 在 `App.tsx` 增加 `<Route path="..." element={...} />`

View file

@ -1,4 +1,4 @@
import { Navigate, Route, Routes } from "react-router-dom";
import { Navigate, Route, Routes, useLocation } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { AppLayout } from "./layout/AppLayout";
import { queryKeys } from "./constants/queryKeys";
@ -8,6 +8,9 @@ import { NePage } from "./pages/NePage";
import { UmePage } from "./pages/UmePage";
import { WebcrtPage } from "./pages/WebcrtPage";
import { TopologyPage } from "./pages/TopologyPage";
import { NetworkLayout } from "./pages/network/NetworkLayout";
import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage";
import { NetworkPlaceholderPage } from "./pages/network/NetworkPlaceholderPage";
import { LoginPage } from "./pages/LoginPage";
import { UsersPage } from "./pages/UsersPage";
import { AuditPage } from "./pages/AuditPage";
@ -16,6 +19,12 @@ import { ForceChangePasswordPage } from "./pages/ForceChangePasswordPage";
import { fetchIntegrationStatus } from "./services/api";
import { useAuth } from "./auth/AuthContext";
/** Preserve ?ne_id=… when redirecting legacy /webcrt into the network module. */
function WebcrtLegacyRedirect() {
const { search } = useLocation();
return <Navigate to={`/network/webcrt${search}`} replace />;
}
function ProtectedApp() {
const { ready, user } = useAuth();
const integrationsQuery = useQuery({
@ -71,10 +80,19 @@ function ProtectedApp() {
<Route path="/" element={<WorkbenchPage />} />
<Route path="/workbench" element={<Navigate to="/" replace />} />
<Route path="/ume" element={<UmePage />} />
<Route path="/ne" element={<NePage />} />
<Route path="/collect" element={<CollectPage />} />
<Route path="/webcrt" element={<WebcrtPage />} />
<Route path="/topology" element={<TopologyPage />} />
<Route path="/network" element={<NetworkLayout />}>
<Route index element={<Navigate to="devices" replace />} />
<Route path="devices" element={<NePage />} />
<Route path="topology" element={<TopologyPage />} />
<Route path="alarms" element={<NetworkAlarmsPage />} />
<Route path="webcrt" element={<WebcrtPage />} />
<Route path="tasks/config-sync" element={<CollectPage />} />
<Route path="tasks/port-traffic" element={<NetworkPlaceholderPage kind="port-traffic" />} />
</Route>
<Route path="/ne" element={<Navigate to="/network/devices" replace />} />
<Route path="/collect" element={<Navigate to="/network/tasks/config-sync" replace />} />
<Route path="/topology" element={<Navigate to="/network/topology" replace />} />
<Route path="/webcrt" element={<WebcrtLegacyRedirect />} />
<Route path="/users" element={<UsersPage />} />
<Route path="/audit" element={<AuditPage />} />
<Route path="/api-keys" element={<ApiTokensPage />} />

View file

@ -27,40 +27,13 @@ export const MODULES: readonly ModuleDefinition[] = [
titleKey: "layout.titleUme",
},
{
moduleId: "managed-ne",
path: "/ne",
moduleId: "network",
path: "/network",
section: "operations",
labelKey: "workbench.cards.managedNe",
descKey: "workbench.cards.managedNeDesc",
labelKey: "workbench.cards.network",
descKey: "workbench.cards.networkDesc",
iconTone: "green",
titleKey: "layout.titleManagedNe",
},
{
moduleId: "collect",
path: "/collect",
section: "operations",
labelKey: "workbench.cards.collect",
descKey: "workbench.cards.collectDesc",
iconTone: "amber",
titleKey: "layout.titleCollect",
},
{
moduleId: "webcrt",
path: "/webcrt",
section: "operations",
labelKey: "workbench.cards.webcrt",
descKey: "workbench.cards.webcrtDesc",
iconTone: "slate",
titleKey: "layout.titleWebcrt",
},
{
moduleId: "topology",
path: "/topology",
section: "operations",
labelKey: "workbench.cards.topology",
descKey: "workbench.cards.topologyDesc",
iconTone: "blue",
titleKey: "layout.titleTopology",
titleKey: "layout.titleNetwork",
},
{
moduleId: "users",

View file

@ -0,0 +1,50 @@
/**
* Left-nav tree for the Network Management module.
* Add leaf items here when extending (devices / topology / alarms / tasks).
*/
export type NetworkNavGroupId = "detail" | "tasks";
export type NetworkNavItem = {
id: string;
path: string;
labelKey: string;
group: NetworkNavGroupId;
};
export type NetworkNavGroup = {
id: NetworkNavGroupId;
labelKey: string;
items: NetworkNavItem[];
};
export const NETWORK_NAV: readonly NetworkNavGroup[] = [
{
id: "detail",
labelKey: "network.nav.detail",
items: [
{ id: "devices", path: "/network/devices", labelKey: "network.nav.devices", group: "detail" },
{ id: "topology", path: "/network/topology", labelKey: "network.nav.topology", group: "detail" },
{ id: "alarms", path: "/network/alarms", labelKey: "network.nav.alarms", group: "detail" },
{ id: "webcrt", path: "/network/webcrt", labelKey: "network.nav.webcrt", group: "detail" },
],
},
{
id: "tasks",
labelKey: "network.nav.tasks",
items: [
{
id: "config-sync",
path: "/network/tasks/config-sync",
labelKey: "network.nav.configSync",
group: "tasks",
},
{
id: "port-traffic",
path: "/network/tasks/port-traffic",
labelKey: "network.nav.portTraffic",
group: "tasks",
},
],
},
] as const;

View file

@ -23,6 +23,8 @@ const en = {
cards: {
umeSync: "UME Sync",
umeSyncDesc: "UME alarms, subscription & inventory",
network: "Network Management",
networkDesc: "Devices, topology, alarms, WebCRT, and monitoring tasks",
managedNe: "NE Management",
managedNeDesc: "Multi-vendor SSH/Telnet NEs & connectivity test",
collect: "Batch Collect",
@ -39,9 +41,33 @@ const en = {
apiKeysDesc: "Issue MCP/script tokens per user with expiry",
},
},
network: {
title: "Network Management",
nav: {
detail: "Network details",
tasks: "Monitoring tasks",
devices: "Device list",
topology: "Topology",
alarms: "Alarms",
webcrt: "WebCRT",
configSync: "Collection tasks",
portTraffic: "Port traffic",
},
placeholder: {
title: "Coming soon",
body: "This capability is not available yet.",
portTrafficTitle: "Port traffic monitoring",
portTrafficBody: "Planned: collect per-port traffic on a schedule and show trends. Suggested path:",
portTrafficStep1: "Task definition: NE/port scope, interval, retention",
portTrafficStep2: "Collection: prefer CLI (display/show interface); SNMP later at scale",
portTrafficStep3: "Storage: time series or hourly rollups (in/out bps, errors)",
portTrafficStep4: "UI: task list + per-port trend charts",
},
},
layout: {
title: "UME Integration",
titleUme: "UME Sync",
titleNetwork: "Network Management",
titleManagedNe: "NE Management",
titleCollect: "Batch Collect",
titleWebcrt: "WebCRT",
@ -614,6 +640,7 @@ const en = {
},
alarms: {
title: "Current alarms",
movedHint: "Current alarm query moved to Network Management →",
showPanel: "Expand",
hidePanel: "Collapse",
keywordPh: "keyword(alarm key/cause/ne_name/host_name/ip…)",

View file

@ -23,6 +23,8 @@ const zh = {
cards: {
umeSync: "UME同步",
umeSyncDesc: "UME 告警同步、订阅与清单",
network: "网络管理",
networkDesc: "设备、拓扑、告警、WebCRT 与监控任务",
managedNe: "网元管理",
managedNeDesc: "多厂商 SSH/Telnet 网元与连通性测试",
collect: "批量采集",
@ -39,9 +41,33 @@ const zh = {
apiKeysDesc: "为用户生成 MCP/脚本用 Token,可设有效期",
},
},
network: {
title: "网络管理",
nav: {
detail: "网络详情",
tasks: "监控任务",
devices: "设备列表",
topology: "拓扑信息",
alarms: "告警信息",
webcrt: "WebCRT",
configSync: "采集任务",
portTraffic: "端口流量监控",
},
placeholder: {
title: "功能规划中",
body: "该能力尚未开放。",
portTrafficTitle: "端口流量监控",
portTrafficBody: "规划中:按任务采集设备端口流量并展示趋势。建议路径如下。",
portTrafficStep1: "任务定义:选择网元/端口范围、采集周期与保留策略",
portTrafficStep2: "采集:优先 CLI(display/show interface),规模上来后再 SNMP",
portTrafficStep3: "存储:端口 in/out bps、error 等时序或按小时汇总",
portTrafficStep4: "界面:任务列表 + 单端口趋势图",
},
},
layout: {
title: "UME 对接",
titleUme: "UME同步",
titleNetwork: "网络管理",
titleManagedNe: "网元管理",
titleCollect: "批量采集",
titleWebcrt: "WebCRT",
@ -611,6 +637,7 @@ const zh = {
},
alarms: {
title: "当前告警",
movedHint: "当前告警查询已迁至网络管理 →",
showPanel: "展开",
hidePanel: "收起",
keywordPh: "keyword(告警键/原因/ne_name/host_name/ip 等)",

View file

@ -2612,3 +2612,140 @@ pre {
opacity: 0.6;
cursor: not-allowed;
}
/* —— Network Management shell (left nav + main) —— */
.network-shell {
display: flex;
height: calc(100vh - 56px);
min-height: 480px;
margin: -20px -28px -32px;
background: #e8edf3;
overflow: hidden;
}
.network-nav {
width: 220px;
flex-shrink: 0;
display: flex;
flex-direction: column;
background: #f7f9fc;
border-right: 1px solid #d0d7e2;
min-height: 0;
}
.network-nav__scroll {
flex: 1;
min-height: 0;
overflow: auto;
padding: 6px 0 16px;
}
.network-nav__group {
margin: 0;
}
.app-main .network-nav__group-toggle,
.network-nav__group-toggle {
display: flex;
align-items: center;
gap: 6px;
width: 100%;
padding: 10px 12px;
border: 0;
border-radius: 0;
background: transparent;
color: #0f172a;
font-size: 13px;
font-weight: 600;
text-align: left;
cursor: pointer;
}
.app-main .network-nav__group-toggle:hover,
.network-nav__group-toggle:hover {
background: #eef2f7;
}
.network-nav__group.is-active-group .network-nav__group-toggle {
color: #1565c0;
}
.network-nav__chevron {
display: inline-block;
width: 1em;
color: #64748b;
font-size: 12px;
line-height: 1;
}
.network-nav__group-label {
flex: 1;
min-width: 0;
}
.network-nav__list {
list-style: none;
margin: 0;
padding: 0 0 6px;
}
.network-nav__link {
display: block;
padding: 7px 12px 7px 30px;
color: #334155;
text-decoration: none;
font-size: 13px;
border-left: 3px solid transparent;
}
.network-nav__link:hover {
background: #eef2f7;
color: #0f172a;
}
.network-nav__link.is-active {
background: #e3effc;
color: #1565c0;
border-left-color: #1565c0;
font-weight: 600;
}
.network-main {
flex: 1;
min-width: 0;
min-height: 0;
overflow: auto;
padding: 16px 20px 24px;
background: #eef2f7;
}
.network-main:has(.topo-page),
.network-main:has(.webcrt-shell) {
padding: 0;
overflow: hidden;
}
.network-main .topo-page,
.network-main .topo-page.is-sidebar-collapsed {
height: 100%;
margin: 0;
min-height: 0;
}
.network-main .webcrt-shell {
height: 100%;
margin: 0;
min-height: 0;
border: 0;
}
.network-placeholder h2 {
margin: 0 0 8px;
}
.network-placeholder__list {
margin: 12px 0 0;
padding-left: 1.25rem;
color: #475569;
line-height: 1.6;
}

View file

@ -26,7 +26,7 @@ import { useToast } from "../hooks/useToast";
import type { ManagedNeItem } from "../types";
import { pageCount } from "../utils/display";
import { formatSystemTime } from "../utils/time";
import { openNewModuleWindow } from "../utils/moduleWindows";
import { openOrFocusModule } from "../utils/moduleWindows";
import {
defaultHopTemplate,
isAutoHopTemplate,
@ -779,9 +779,9 @@ export function NePage() {
type="button"
className="link-btn"
onClick={() =>
openNewModuleWindow({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(row.id)}`,
openOrFocusModule({
moduleId: "network",
path: `/network/webcrt?ne_id=${encodeURIComponent(row.id)}`,
})
}
title={t("managedNe.openTerminal")}

View file

@ -35,7 +35,7 @@ import { queryKeys } from "../constants/queryKeys";
import { HelpHint } from "../components/HelpHint";
import { useI18n } from "../i18n";
import { useToast } from "../hooks/useToast";
import { openNewModuleWindow, openOrFocusModule } from "../utils/moduleWindows";
import { openOrFocusModule } from "../utils/moduleWindows";
import type {
ManagedNeItem,
TopologyDiscoverNeResult,
@ -675,16 +675,16 @@ export function TopologyPage() {
const managedId = node?.data.managed_ne_id;
const umeId = node?.data.ume_ne_id;
if (managedId) {
openNewModuleWindow({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(managedId)}`,
openOrFocusModule({
moduleId: "network",
path: `/network/webcrt?ne_id=${encodeURIComponent(managedId)}`,
});
return;
}
if (umeId) {
openNewModuleWindow({
moduleId: "webcrt",
path: `/webcrt?ne_id=${encodeURIComponent(umeId)}&source=ume`,
openOrFocusModule({
moduleId: "network",
path: `/network/webcrt?ne_id=${encodeURIComponent(umeId)}&source=ume`,
});
return;
}
@ -697,8 +697,8 @@ export function TopologyPage() {
const umeId = node?.data.ume_ne_id;
if (managedId) {
openOrFocusModule({
moduleId: "managed-ne",
path: "/ne",
moduleId: "network",
path: "/network/devices",
});
return;
}

View file

@ -3,7 +3,6 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
apiPost,
disconnectUmeToken,
fetchUmeCurrentAlarms,
fetchUmeNe,
cancelUmeAlarmSubscription,
clearLocalUmeAlarmSubscription,
@ -31,6 +30,7 @@ import type { UmeAlarmSubscriptionStatus, UmeKeyAlertRuleItem } from "../types";
import { pageCount, runtimeIntervalLabel } from "../utils/display";
import { runtimeLastError, wsConnectionLabel } from "../utils/runtimeMessages";
import { formatSystemTime } from "../utils/time";
import { openOrFocusModule } from "../utils/moduleWindows";
export function UmePage() {
const { t } = useI18n();
@ -48,13 +48,6 @@ export function UmePage() {
const [nePanelOpen, setNePanelOpen] = useState(false);
const [syncStatusPanelOpen, setSyncStatusPanelOpen] = useState(false);
const [keyAlertPanelOpen, setKeyAlertPanelOpen] = useState(false);
const [curSeverity, setCurSeverity] = useState("");
const [curCleared, setCurCleared] = useState("");
const [curHostName, setCurHostName] = useState("");
const [curKeyword, setCurKeyword] = useState("");
const [curPage, setCurPage] = useState(1);
const [curPageSize, setCurPageSize] = useState(50);
const [alarmsPanelOpen, setAlarmsPanelOpen] = useState(false);
const [keyAlertMatchType, setKeyAlertMatchType] = useState<"notification_id" | "keyword">("keyword");
const [keyAlertMatchValue, setKeyAlertMatchValue] = useState("");
const [keyAlertLabel, setKeyAlertLabel] = useState("");
@ -264,19 +257,6 @@ export function UmePage() {
}
return map;
}, [cliTargetsQuery.data]);
const currentQuery = useQuery({
queryKey: queryKeys.umeCurrentAlarms(curSeverity, curCleared, curHostName, curKeyword, curPage, curPageSize),
queryFn: () =>
fetchUmeCurrentAlarms({
severity: curSeverity,
isCleared: curCleared,
hostName: curHostName,
keyword: curKeyword,
page: curPage,
pageSize: curPageSize,
}),
staleTime: 5000,
});
const runningTasks = (syncStatusQuery.data?.items || []).filter((x) => String(x.status || "").toLowerCase() === "running");
const runtimeTasks = syncStatusQuery.data?.runtime_tasks || [];
@ -425,8 +405,6 @@ export function UmePage() {
const syncPages = pageCount(syncTotal, syncPageSize);
const neTotal = Number(neQuery.data?.total || 0);
const nePages = pageCount(neTotal, nePageSize);
const curTotal = Number(currentQuery.data?.total || 0);
const curPages = pageCount(curTotal, curPageSize);
const keyAlertForwarder = keyAlertMonitorQuery.data?.forwarder;
const keyAlertRules = keyAlertMonitorQuery.data?.rules || [];
@ -1350,112 +1328,16 @@ export function UmePage() {
<section className="panel">
<div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2>
<button type="button" className="link-btn" onClick={() => setAlarmsPanelOpen((x) => !x)}>
{alarmsPanelOpen ? t("ume.alarms.hidePanel") : t("ume.alarms.showPanel")}
</button>
</div>
{alarmsPanelOpen ? (
<>
<div className="filter-inline">
<input value={curKeyword} placeholder={t("ume.alarms.keywordPh")} onChange={(e) => setCurKeyword(e.target.value)} />
<input value={curHostName} placeholder={t("ume.alarms.hostNamePh")} onChange={(e) => setCurHostName(e.target.value)} />
<select value={curSeverity} onChange={(e) => setCurSeverity(e.target.value)}>
<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>
</select>
<select value={curCleared} onChange={(e) => setCurCleared(e.target.value)}>
<option value="">{t("ume.alarms.clearedAll")}</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
<button
type="button"
title={t("ume.alarms.clearTitle")}
onClick={() => {
setCurKeyword("");
setCurHostName("");
setCurSeverity("");
setCurCleared("");
setCurPage(1);
}}
disabled={!curKeyword.trim() && !curHostName.trim() && !curSeverity && !curCleared}
>
{t("common.clearFilters")}
</button>
</div>
<table>
<thead>
<tr>
<th>time_created</th>
<th>severity</th>
<th>ne_id</th>
<th>host_name</th>
<th>ne_type</th>
<th>cause</th>
</tr>
</thead>
<tbody>
{(currentQuery.data?.items || []).map((x) => (
<tr key={x.alarm_key}>
<td>{formatSystemTime(x.time_created)}</td>
<td>{x.perceived_severity}</td>
<td>{x.ne_id}</td>
<td>
{(x.host_name || "").trim() ? (
<button
className="link-btn"
type="button"
onClick={() => {
setCurHostName(x.host_name || "");
setCurKeyword("");
setCurPage(1);
}}
title={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 className="pager">
<div className="pager__meta">{t("common.pagerMeta", { total: curTotal, page: curPage, pages: curPages })}</div>
<div className="pager__controls">
<button className="pager__btn" onClick={() => setCurPage(Math.max(1, curPage - 1))} disabled={curPage <= 1}>
{t("common.prevPage")}
</button>
<button className="pager__btn" onClick={() => setCurPage(curPage + 1)} disabled={curPage >= curPages}>
{t("common.nextPage")}
</button>
<select
className="pager__size"
value={String(curPageSize)}
onChange={(e) => {
setCurPageSize(Number(e.target.value) || 50);
setCurPage(1);
}}
>
<option value="50">{perPage(50)}</option>
<option value="100">{perPage(100)}</option>
<option value="200">{perPage(200)}</option>
<option value="500">{perPage(500)}</option>
</select>
</div>
</div>
</>
) : null}
<p className="muted">
{t("ume.alarms.movedHint")}{" "}
<a href="/network/alarms" onClick={(e) => {
e.preventDefault();
openOrFocusModule({ moduleId: "network", path: "/network/alarms" });
}}>
{t("network.nav.alarms")}
</a>
</p>
</section>
</div>

View file

@ -0,0 +1,171 @@
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { fetchUmeCurrentAlarms } from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
/** Current UME alarms query view for Network Management. */
export function NetworkAlarmsPage() {
const { t } = useI18n();
const [curSeverity, setCurSeverity] = useState("");
const [curCleared, setCurCleared] = useState("");
const [curHostName, setCurHostName] = useState("");
const [curKeyword, setCurKeyword] = useState("");
const [curPage, setCurPage] = useState(1);
const [curPageSize, setCurPageSize] = useState(50);
const currentQuery = useQuery({
queryKey: queryKeys.umeCurrentAlarms(curSeverity, curCleared, curHostName, curKeyword, curPage, curPageSize),
queryFn: () =>
fetchUmeCurrentAlarms({
severity: curSeverity,
isCleared: curCleared,
hostName: curHostName,
keyword: curKeyword,
page: curPage,
pageSize: curPageSize,
}),
staleTime: 5000,
});
const curTotal = Number(currentQuery.data?.total || 0);
const curPages = pageCount(curTotal, curPageSize);
const perPage = (n: number) => t("common.perPage", { n: String(n) });
const hasFilters = Boolean(curKeyword.trim() || curHostName.trim() || curSeverity || curCleared);
return (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2>
</div>
<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);
}}
/>
<select
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>
</select>
<select
value={curCleared}
onChange={(e) => {
setCurCleared(e.target.value);
setCurPage(1);
}}
>
<option value="">{t("ume.alarms.clearedAll")}</option>
<option value="true">true</option>
<option value="false">false</option>
</select>
<button
type="button"
title={t("ume.alarms.clearTitle")}
onClick={() => {
setCurKeyword("");
setCurHostName("");
setCurSeverity("");
setCurCleared("");
setCurPage(1);
}}
disabled={!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}
<table>
<thead>
<tr>
<th>time_created</th>
<th>severity</th>
<th>ne_id</th>
<th>host_name</th>
<th>ne_type</th>
<th>cause</th>
</tr>
</thead>
<tbody>
{(currentQuery.data?.items || []).map((x) => (
<tr key={x.alarm_key}>
<td>{formatSystemTime(x.time_created)}</td>
<td>{x.perceived_severity}</td>
<td>{x.ne_id}</td>
<td>
{(x.host_name || "").trim() ? (
<button
className="link-btn"
type="button"
onClick={() => {
setCurHostName(x.host_name || "");
setCurKeyword("");
setCurPage(1);
}}
title={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 className="pager">
<div className="pager__meta">{t("common.pagerMeta", { total: curTotal, page: curPage, pages: curPages })}</div>
<div className="pager__controls">
<button className="pager__btn" type="button" onClick={() => setCurPage(Math.max(1, curPage - 1))} disabled={curPage <= 1}>
{t("common.prevPage")}
</button>
<button className="pager__btn" type="button" onClick={() => setCurPage(curPage + 1)} disabled={curPage >= curPages}>
{t("common.nextPage")}
</button>
<select
className="pager__size"
value={String(curPageSize)}
onChange={(e) => {
setCurPageSize(Number(e.target.value) || 50);
setCurPage(1);
}}
>
<option value="50">{perPage(50)}</option>
<option value="100">{perPage(100)}</option>
<option value="200">{perPage(200)}</option>
<option value="500">{perPage(500)}</option>
</select>
</div>
</div>
</section>
);
}

View file

@ -0,0 +1,88 @@
import { useEffect, useState } from "react";
import { NavLink, Outlet, useLocation } from "react-router-dom";
import { NETWORK_NAV, type NetworkNavGroupId } from "../../config/networkNav";
import { useI18n } from "../../i18n";
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}/`),
);
}
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;
});
useEffect(() => {
for (const g of NETWORK_NAV) {
if (groupContainsPath(g.id, pathname)) {
setOpenGroups((prev) => (prev[g.id] ? prev : { ...prev, [g.id]: true }));
}
}
}, [pathname]);
const toggleGroup = (id: NetworkNavGroupId) => {
setOpenGroups((prev) => ({ ...prev, [id]: !prev[id] }));
};
return (
<div className="network-shell">
<aside className="network-nav" aria-label={t("network.title")}>
<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)}
>
<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"}
>
{t(item.labelKey)}
</NavLink>
</li>
))}
</ul>
) : null}
</div>
);
})}
</nav>
</aside>
<div className="network-main">
<Outlet />
</div>
</div>
);
}

View file

@ -0,0 +1,26 @@
import { useI18n } from "../../i18n";
type Props = {
kind: "port-traffic";
};
export function NetworkPlaceholderPage({ kind }: Props) {
const { t } = useI18n();
const titleKey = kind === "port-traffic" ? "network.placeholder.portTrafficTitle" : "network.placeholder.title";
const bodyKey = kind === "port-traffic" ? "network.placeholder.portTrafficBody" : "network.placeholder.body";
return (
<section className="panel network-placeholder">
<h2>{t(titleKey)}</h2>
<p className="muted">{t(bodyKey)}</p>
{kind === "port-traffic" ? (
<ul className="network-placeholder__list">
<li>{t("network.placeholder.portTrafficStep1")}</li>
<li>{t("network.placeholder.portTrafficStep2")}</li>
<li>{t("network.placeholder.portTrafficStep3")}</li>
<li>{t("network.placeholder.portTrafficStep4")}</li>
</ul>
) : null}
</section>
);
}