Add ZTE-first port traffic monitoring with task wizard and ops wall.

Collect interface bit/s via CLI on a schedule, store samples in Postgres, and chart trends with uPlot.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-31 14:56:50 +08:00
parent a95c6598bb
commit efe8f58cd2
25 changed files with 2880 additions and 36 deletions

View file

@ -42,7 +42,7 @@ src/
| `/network/topology` | redirect → `/topology` | — |
| `/network/tasks/collect` | 采集任务 | `network` |
| `/network/tasks/config-sync` | 配置同步 | `network` |
| `/network/tasks/port-traffic` | 端口流量(占位) | `network` |
| `/network/tasks/port-traffic` | 端口流量监控(任务向导 + 大屏) | `network` |
| `/topology` | 拓扑管理 | `topology` |
| `/webcrt` | WebCRT 终端 | `webcrt` |
| `/collect` | redirect → `/network/tasks/collect` | — |
@ -105,6 +105,15 @@ src/
- 进程启动宽限:`NETX_CONFIG_SYNC_STARTUP_GRACE_SEC`(默认 3600)仅约束**新建**自动周期,不影响续跑
- 前端:`/network/tasks/config-sync`(看板)+ `/network/configs`(查看)
## 端口流量监控
- API:`/v1/port-traffic/*`(任务 CRUD、discover/ports、samples、dashboard)
- 厂商:本期仅 ZTE(`show interface brief` / `show interface {if}`),解析 Rate period **bit/s**
- 调度:`NETX_PORT_TRAFFIC_SCHEDULER_ENABLED`(默认开),tick `NETX_PORT_TRAFFIC_SCHEDULER_TICK_SEC`(默认 15)
- 单飞:同一任务同时只允许一轮采集;崩溃启动清除 `collect_running`
- 保留:按任务 `retention_days` 清理过期 sample
- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 监控大屏)
## WebCRT
- API:`POST /v1/webcrt/sessions`(`ne_id` 或 `ume_ne_id`)、`WS /v1/webcrt/sessions/{id}/ws`、`DELETE /v1/webcrt/sessions/{id}`

9
web/package-lock.json generated
View file

@ -14,7 +14,8 @@
"@xyflow/react": "^12.11.2",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
"react-router-dom": "^7.14.2",
"uplot": "^1.6.32"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@ -2889,6 +2890,12 @@
"browserslist": ">= 4.21.0"
}
},
"node_modules/uplot": {
"version": "1.6.32",
"resolved": "https://registry.npmjs.org/uplot/-/uplot-1.6.32.tgz",
"integrity": "sha512-KIMVnG68zvu5XXUbC4LQEPnhwOxBuLyW1AHtpm6IKTXImkbLgkMy+jabjLgSLMasNuGGzQm/ep3tOkyTxpiQIw==",
"license": "MIT"
},
"node_modules/uri-js": {
"version": "4.4.1",
"resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz",

View file

@ -19,7 +19,8 @@
"@xyflow/react": "^12.11.2",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.2"
"react-router-dom": "^7.14.2",
"uplot": "^1.6.32"
},
"devDependencies": {
"@eslint/js": "^10.0.1",

View file

@ -13,7 +13,7 @@ import { NetworkLayout } from "./pages/network/NetworkLayout";
import { NetworkDevicesPage } from "./pages/network/NetworkDevicesPage";
import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage";
import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage";
import { NetworkPlaceholderPage } from "./pages/network/NetworkPlaceholderPage";
import { PortTrafficPage } from "./pages/network/PortTrafficPage";
import { LoginPage } from "./pages/LoginPage";
import { UsersPage } from "./pages/UsersPage";
import { AuditPage } from "./pages/AuditPage";
@ -95,7 +95,7 @@ function ProtectedApp() {
<Route path="webcrt" element={<NetworkWebcrtRedirect />} />
<Route path="tasks/collect" element={<CollectPage />} />
<Route path="tasks/config-sync" element={<ConfigSyncPage />} />
<Route path="tasks/port-traffic" element={<NetworkPlaceholderPage kind="port-traffic" />} />
<Route path="tasks/port-traffic" element={<PortTrafficPage />} />
</Route>
<Route path="/collect" element={<Navigate to="/network/tasks/collect" replace />} />
<Route path="/users" element={<UsersPage />} />

View file

@ -59,4 +59,10 @@ export const queryKeys = {
networkConfigs: (page: number, keyword: string, source: string, vendor: string) =>
["networkConfigs", page, keyword, source, vendor] as const,
networkConfigDetail: (source: string, id: string) => ["networkConfigDetail", source, id] as const,
portTrafficDashboard: ["portTrafficDashboard"] as const,
portTrafficTasksAll: ["portTrafficTasks"] as const,
portTrafficTasks: (page: number) => ["portTrafficTasks", page] as const,
portTrafficTargets: (taskId: string) => ["portTrafficTargets", taskId] as const,
portTrafficSamples: (targetId: string, rangeHours: number) =>
["portTrafficSamples", targetId, rangeHours] as const,
};

View file

@ -60,12 +60,65 @@ const en = {
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",
},
},
portTraffic: {
title: "Port traffic",
create: "New task",
backList: "Back to list",
created: "Task created",
createFailed: "Create failed",
started: "Started",
paused: "Paused",
stopped: "Stopped",
deleted: "Deleted",
confirmDelete: "Delete this task and its samples?",
empty: "No monitoring tasks yet. Create one to start.",
wall: "Ops wall",
start: "Start",
pause: "Pause",
stop: "Stop",
delete: "Delete",
step1: "Params",
step2: "NEs",
step3: "Ports",
step4: "Confirm",
prev: "Back",
next: "Next",
fieldTitle: "Task name",
titlePh: "e.g. Access uplinks",
titleRequired: "Task name is required",
portsRequired: "Select at least one port",
interval: "Interval (sec)",
retention: "Retention (days)",
concurrency: "Concurrency",
startNow: "Start after create",
neKeywordPh: "Name / IP / vendor",
selectedNe: "{{count}} NE(s) selected",
selectedPorts: "{{count}} port(s) selected",
pickNeFirst: "Select NEs in the previous step first",
discover: "Discover ports",
confirmCreate: "Create task",
wallTask: "Task",
wallPort: "Interface",
pickPort: "Select interface",
range: "Range",
kpi: {
tasks: "Tasks",
running: "Running",
ports: "Ports",
samples24h: "Samples 24h",
},
col: {
title: "Task",
status: "Status",
ports: "Ports",
interval: "Interval",
last: "Last collect",
actions: "Actions",
source: "Source",
name: "Device",
vendor: "Vendor",
},
},
configSync: {

View file

@ -60,12 +60,65 @@ const zh = {
placeholder: {
title: "功能规划中",
body: "该能力尚未开放。",
portTrafficTitle: "端口流量监控",
portTrafficBody: "规划中:按任务采集设备端口流量并展示趋势。建议路径如下。",
portTrafficStep1: "任务定义:选择网元/端口范围、采集周期与保留策略",
portTrafficStep2: "采集:优先 CLI(display/show interface),规模上来后再 SNMP",
portTrafficStep3: "存储:端口 in/out bps、error 等时序或按小时汇总",
portTrafficStep4: "界面:任务列表 + 单端口趋势图",
},
},
portTraffic: {
title: "端口流量监控",
create: "新建任务",
backList: "返回列表",
created: "任务已创建",
createFailed: "创建失败",
started: "已启动",
paused: "已暂停",
stopped: "已停止",
deleted: "已删除",
confirmDelete: "删除该任务及其采样数据?",
empty: "暂无监控任务。请先创建任务。",
wall: "监控大屏",
start: "启动",
pause: "暂停",
stop: "停止",
delete: "删除",
step1: "参数",
step2: "选网元",
step3: "选端口",
step4: "确认",
prev: "上一步",
next: "下一步",
fieldTitle: "任务名称",
titlePh: "例如:接入侧上联",
titleRequired: "请填写任务名称",
portsRequired: "请至少选择一个端口",
interval: "周期(秒)",
retention: "保留(天)",
concurrency: "并发",
startNow: "创建后立即启动",
neKeywordPh: "名称 / IP / 厂商",
selectedNe: "已选 {{count}} 台网元",
selectedPorts: "已选 {{count}} 个端口",
pickNeFirst: "请先在上一步选择网元",
discover: "拉取端口",
confirmCreate: "创建任务",
wallTask: "任务",
wallPort: "接口",
pickPort: "选择接口",
range: "时间范围",
kpi: {
tasks: "任务数",
running: "运行中",
ports: "监控端口",
samples24h: "24h 样点",
},
col: {
title: "任务",
status: "状态",
ports: "端口",
interval: "周期",
last: "最近采集",
actions: "操作",
source: "来源",
name: "设备",
vendor: "厂商",
},
},
configSync: {

View file

@ -2918,3 +2918,196 @@ pre {
color: #475569;
line-height: 1.6;
}
/* Port traffic monitoring */
.pt-wizard__steps {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.pt-wizard__step {
border: 1px solid #cbd5e1;
background: #f8fafc;
color: #334155;
border-radius: 6px;
padding: 6px 10px;
font-size: 13px;
cursor: pointer;
}
.pt-wizard__step.is-active {
border-color: #0f766e;
background: #ccfbf1;
color: #115e59;
}
.pt-wizard__ne-block {
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 1px solid #e2e8f0;
}
.pt-wizard__confirm-list {
margin: 8px 0 16px;
padding-left: 1.2rem;
max-height: 220px;
overflow: auto;
color: #334155;
}
.pt-wall-page__filters {
margin-bottom: 12px;
gap: 12px 18px;
align-items: flex-end;
}
.pt-wall-page__filters label {
display: inline-flex;
flex-direction: column;
gap: 4px;
font-size: 12px;
color: #64748b;
}
.pt-wall-page__filters select {
min-width: 160px;
}
.pt-wall {
--pt-bg: #0b1220;
--pt-panel: #111827;
--pt-line: rgba(148, 163, 184, 0.22);
--pt-text: #e2e8f0;
--pt-muted: #94a3b8;
--pt-in: #2dd4bf;
--pt-out: #f59e0b;
background:
linear-gradient(180deg, rgba(15, 23, 42, 0.92), rgba(11, 18, 32, 0.98)),
repeating-linear-gradient(
0deg,
transparent,
transparent 23px,
rgba(148, 163, 184, 0.05) 24px
),
repeating-linear-gradient(
90deg,
transparent,
transparent 23px,
rgba(148, 163, 184, 0.05) 24px
);
border: 1px solid #1f2937;
border-radius: 10px;
padding: 16px 18px 12px;
color: var(--pt-text);
}
.pt-wall__head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 14px;
}
.pt-wall__title {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
}
.pt-wall__ne {
font-size: 13px;
color: var(--pt-muted);
}
.pt-wall__if {
font-size: 20px;
font-weight: 650;
letter-spacing: 0.02em;
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
}
.pt-wall__range {
font-size: 12px;
color: var(--pt-muted);
white-space: nowrap;
}
.pt-wall__kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
margin-bottom: 14px;
}
.pt-wall__kpi {
background: rgba(17, 24, 39, 0.72);
border: 1px solid var(--pt-line);
border-radius: 8px;
padding: 10px 12px;
}
.pt-wall__kpi-label {
font-size: 11px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--pt-muted);
margin-bottom: 4px;
}
.pt-wall__kpi-value {
font-size: 22px;
font-weight: 650;
font-variant-numeric: tabular-nums;
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
line-height: 1.2;
}
.pt-wall__kpi--in .pt-wall__kpi-value {
color: var(--pt-in);
}
.pt-wall__kpi--out .pt-wall__kpi-value {
color: var(--pt-out);
}
.pt-wall__kpi-unit,
.pt-wall__kpi-sep {
margin-left: 4px;
font-size: 12px;
color: var(--pt-muted);
font-weight: 500;
}
.pt-wall__chart {
min-height: 240px;
width: 100%;
}
.pt-wall__chart .uplot,
.pt-wall__chart .u-wrap {
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
}
.pt-wall__chart .u-legend {
color: var(--pt-muted);
}
.pt-wall__empty {
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
color: var(--pt-muted);
border: 1px dashed rgba(148, 163, 184, 0.28);
border-radius: 8px;
}
@media (max-width: 900px) {
.pt-wall__kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}

View file

@ -1,26 +1,13 @@
import { useI18n } from "../../i18n";
type Props = {
kind: "port-traffic";
kind?: string;
};
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";
/** Generic placeholder for unfinished network sub-pages. */
export function NetworkPlaceholderPage(_props: Props) {
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}
<h2>Coming soon</h2>
<p className="muted">This capability is not available yet.</p>
</section>
);
}

View file

@ -0,0 +1,645 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createPortTrafficTask,
deletePortTrafficTask,
discoverPortTrafficPorts,
fetchCliTargets,
fetchPortTrafficDashboard,
fetchPortTrafficSamples,
fetchPortTrafficTargets,
fetchPortTrafficTasks,
pausePortTrafficTask,
startPortTrafficTask,
stopPortTrafficTask,
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import type { CliTargetItem, PortTrafficDiscoverPort, PortTrafficTargetIn } from "../../types";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
import { PortTrafficWall } from "./PortTrafficWall";
const POLL_MS = 5000;
const TARGET_PAGE_SIZE = 20;
type PortPick = PortTrafficTargetIn & { key: string };
function neKey(source: string, id: string) {
return `${source}:${id}`;
}
function portKey(source: string, id: string, ifname: string) {
return `${source}:${id}:${ifname}`;
}
function formatBw(bps: number) {
if (!bps) return "—";
if (bps >= 1e9) return `${bps / 1e9}G`;
if (bps >= 1e6) return `${bps / 1e6}M`;
return `${bps}`;
}
export function PortTrafficPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [view, setView] = useState<"list" | "wizard" | "wall">("list");
const [taskPage, setTaskPage] = useState(1);
const [wallTaskId, setWallTaskId] = useState("");
const [wallTargetId, setWallTargetId] = useState("");
const [rangeHours, setRangeHours] = useState(1);
// Wizard state
const [step, setStep] = useState(1);
const [title, setTitle] = useState("");
const [intervalSec, setIntervalSec] = useState(60);
const [retentionDays, setRetentionDays] = useState(7);
const [concurrency, setConcurrency] = useState(5);
const [startNow, setStartNow] = useState(true);
const [neKeyword, setNeKeyword] = useState("");
const [nePage, setNePage] = useState(1);
const [selectedNes, setSelectedNes] = useState<Record<string, CliTargetItem>>({});
const [portsByNe, setPortsByNe] = useState<
Record<string, { loading?: boolean; error?: string; ports: PortTrafficDiscoverPort[]; meta?: { ne_name: string; ne_ip: string; vendor: string } }>
>({});
const [pickedPorts, setPickedPorts] = useState<Record<string, PortPick>>({});
const dashQuery = useQuery({
queryKey: queryKeys.portTrafficDashboard,
queryFn: fetchPortTrafficDashboard,
staleTime: 2000,
refetchInterval: POLL_MS,
});
const tasksQuery = useQuery({
queryKey: queryKeys.portTrafficTasks(taskPage),
queryFn: () => fetchPortTrafficTasks({ page: taskPage, pageSize: 10 }),
staleTime: 2000,
refetchInterval: POLL_MS,
});
const wallTasksQuery = useQuery({
queryKey: queryKeys.portTrafficTasks(0),
queryFn: () => fetchPortTrafficTasks({ page: 1, pageSize: 100 }),
enabled: view === "wall",
staleTime: 5000,
});
const nesQuery = useQuery({
queryKey: queryKeys.cliTargets(neKeyword, nePage, TARGET_PAGE_SIZE),
queryFn: () =>
fetchCliTargets({ source: "all", keyword: neKeyword, page: nePage, pageSize: TARGET_PAGE_SIZE }),
enabled: view === "wizard" && step === 2,
staleTime: 5000,
});
const wallTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(wallTaskId),
queryFn: () => fetchPortTrafficTargets(wallTaskId),
enabled: view === "wall" && Boolean(wallTaskId),
staleTime: 3000,
});
const samplesQuery = useQuery({
queryKey: queryKeys.portTrafficSamples(wallTargetId, rangeHours),
queryFn: () => {
const to = new Date();
const from = new Date(to.getTime() - rangeHours * 3600 * 1000);
return fetchPortTrafficSamples({
targetId: wallTargetId,
from: from.toISOString(),
to: to.toISOString(),
});
},
enabled: view === "wall" && Boolean(wallTargetId),
staleTime: 2000,
refetchInterval: POLL_MS,
});
const invalidateAll = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTasksAll });
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficDashboard });
};
const createMut = useMutation({
mutationFn: createPortTrafficTask,
onSuccess: () => {
showOk(t("portTraffic.created"));
invalidateAll();
resetWizard();
setView("list");
},
onError: (e: Error) => showError(e.message || t("portTraffic.createFailed")),
});
const startMut = useMutation({
mutationFn: startPortTrafficTask,
onSuccess: () => {
showOk(t("portTraffic.started"));
invalidateAll();
},
onError: (e: Error) => showError(e.message),
});
const pauseMut = useMutation({
mutationFn: pausePortTrafficTask,
onSuccess: () => {
showOk(t("portTraffic.paused"));
invalidateAll();
},
onError: (e: Error) => showError(e.message),
});
const stopMut = useMutation({
mutationFn: stopPortTrafficTask,
onSuccess: () => {
showOk(t("portTraffic.stopped"));
invalidateAll();
},
onError: (e: Error) => showError(e.message),
});
const deleteMut = useMutation({
mutationFn: deletePortTrafficTask,
onSuccess: () => {
showOk(t("portTraffic.deleted"));
invalidateAll();
},
onError: (e: Error) => showError(e.message),
});
const resetWizard = () => {
setStep(1);
setTitle("");
setIntervalSec(60);
setRetentionDays(7);
setConcurrency(5);
setStartNow(true);
setSelectedNes({});
setPortsByNe({});
setPickedPorts({});
setNeKeyword("");
setNePage(1);
};
const openWizard = () => {
resetWizard();
setView("wizard");
};
const openWall = (taskId: string) => {
setWallTaskId(taskId);
setWallTargetId("");
setView("wall");
};
const toggleNe = (row: CliTargetItem) => {
const k = neKey(row.source, row.id);
setSelectedNes((prev) => {
const next = { ...prev };
if (next[k]) delete next[k];
else next[k] = row;
return next;
});
};
const discoverNe = async (row: CliTargetItem) => {
const k = neKey(row.source, row.id);
setPortsByNe((prev) => ({ ...prev, [k]: { loading: true, ports: prev[k]?.ports || [] } }));
try {
const source = row.source === "ume" ? "ume" : "managed";
const res = await discoverPortTrafficPorts({ source, id: row.id });
setPortsByNe((prev) => ({
...prev,
[k]: {
ports: res.ports,
meta: { ne_name: res.ne_name || row.name, ne_ip: res.ne_ip || row.ip_address, vendor: res.vendor || row.vendor || "" },
},
}));
} catch (e) {
const msg = e instanceof Error ? e.message : "discover_failed";
setPortsByNe((prev) => ({ ...prev, [k]: { ports: [], error: msg } }));
showError(msg);
}
};
const togglePort = (ne: CliTargetItem, port: PortTrafficDiscoverPort, meta?: { ne_name: string; ne_ip: string; vendor: string }) => {
const source = (ne.source === "ume" ? "ume" : "managed") as "managed" | "ume";
const k = portKey(source, ne.id, port.ifname);
setPickedPorts((prev) => {
const next = { ...prev };
if (next[k]) {
delete next[k];
return next;
}
next[k] = {
key: k,
source,
target_id: ne.id,
ne_name: meta?.ne_name || ne.name,
ne_ip: meta?.ne_ip || ne.ip_address,
vendor: meta?.vendor || ne.vendor || "",
ifname: port.ifname,
if_description: port.description,
bw_bps: port.bw_bps,
};
return next;
});
};
const selectedNeList = useMemo(() => Object.values(selectedNes), [selectedNes]);
const pickedList = useMemo(() => Object.values(pickedPorts), [pickedPorts]);
const wallTargets = wallTargetsQuery.data?.items || [];
const selectedWallTarget = wallTargets.find((x) => x.id === wallTargetId) || null;
useEffect(() => {
if (view !== "wall" || wallTargetId || !wallTargets.length) return;
setWallTargetId(wallTargets[0].id);
}, [view, wallTargetId, wallTargets]);
const submitWizard = () => {
if (!title.trim()) {
showError(t("portTraffic.titleRequired"));
return;
}
if (!pickedList.length) {
showError(t("portTraffic.portsRequired"));
return;
}
createMut.mutate({
title: title.trim(),
interval_sec: intervalSec,
retention_days: retentionDays,
concurrency,
start_now: startNow,
targets: pickedList.map(({ key: _k, ...rest }) => rest),
});
};
const dash = dashQuery.data;
const tasks = tasksQuery.data?.items || [];
const taskPages = pageCount(tasksQuery.data?.total || 0, 10);
return (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("portTraffic.title")}</h2>
<div className="btn-row">
{view !== "list" ? (
<button type="button" onClick={() => setView("list")}>
{t("portTraffic.backList")}
</button>
) : (
<button type="button" className="btn-primary" onClick={openWizard}>
{t("portTraffic.create")}
</button>
)}
</div>
</div>
{view === "list" ? (
<>
<div className="stat-grid" style={{ marginBottom: 16 }}>
<div className="stat-card">
<div className="muted">{t("portTraffic.kpi.tasks")}</div>
<div>{dash?.task_count ?? "—"}</div>
</div>
<div className="stat-card">
<div className="muted">{t("portTraffic.kpi.running")}</div>
<div>{dash?.running_task_count ?? "—"}</div>
</div>
<div className="stat-card">
<div className="muted">{t("portTraffic.kpi.ports")}</div>
<div>{dash?.active_target_count ?? "—"}</div>
</div>
<div className="stat-card">
<div className="muted">{t("portTraffic.kpi.samples24h")}</div>
<div>{dash?.sample_count_24h ?? "—"}</div>
</div>
</div>
<table className="data-table">
<thead>
<tr>
<th>{t("portTraffic.col.title")}</th>
<th>{t("portTraffic.col.status")}</th>
<th>{t("portTraffic.col.ports")}</th>
<th>{t("portTraffic.col.interval")}</th>
<th>{t("portTraffic.col.last")}</th>
<th>{t("portTraffic.col.actions")}</th>
</tr>
</thead>
<tbody>
{!tasks.length ? (
<tr>
<td colSpan={6} className="muted">
{t("portTraffic.empty")}
</td>
</tr>
) : (
tasks.map((row) => (
<tr key={row.id}>
<td>{row.title}</td>
<td>
{row.status}
{row.collect_running ? " · collecting" : ""}
</td>
<td>
{row.active_target_count}/{row.target_count}
</td>
<td>{row.interval_sec}s</td>
<td>{formatSystemTime(row.last_collect_ended_at) || "—"}</td>
<td>
<div className="btn-row">
<button type="button" onClick={() => openWall(row.id)}>
{t("portTraffic.wall")}
</button>
{row.status !== "running" ? (
<button type="button" disabled={startMut.isPending} onClick={() => startMut.mutate(row.id)}>
{t("portTraffic.start")}
</button>
) : (
<button type="button" disabled={pauseMut.isPending} onClick={() => pauseMut.mutate(row.id)}>
{t("portTraffic.pause")}
</button>
)}
{row.status !== "stopped" ? (
<button type="button" disabled={stopMut.isPending} onClick={() => stopMut.mutate(row.id)}>
{t("portTraffic.stop")}
</button>
) : null}
<button
type="button"
disabled={deleteMut.isPending}
onClick={() => {
if (window.confirm(t("portTraffic.confirmDelete"))) deleteMut.mutate(row.id);
}}
>
{t("portTraffic.delete")}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
<div className="pager">
<button type="button" className="pager__btn" disabled={taskPage <= 1} onClick={() => setTaskPage((p) => p - 1)}>
‹
</button>
<span className="muted">
{taskPage}/{Math.max(1, taskPages)}
</span>
<button
type="button"
className="pager__btn"
disabled={taskPage >= taskPages}
onClick={() => setTaskPage((p) => p + 1)}
>
›
</button>
</div>
</>
) : null}
{view === "wizard" ? (
<div className="pt-wizard">
<div className="pt-wizard__steps">
{[1, 2, 3, 4].map((n) => (
<button
key={n}
type="button"
className={`pt-wizard__step${step === n ? " is-active" : ""}`}
onClick={() => setStep(n)}
>
{n}. {t(`portTraffic.step${n}`)}
</button>
))}
</div>
{step === 1 ? (
<div className="config-sync-policy-row">
<label className="config-sync-policy-field">
{t("portTraffic.fieldTitle")}
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t("portTraffic.titlePh")} />
</label>
<label className="config-sync-policy-field">
{t("portTraffic.interval")}
<input
type="number"
min={15}
max={3600}
value={intervalSec}
onChange={(e) => setIntervalSec(Number(e.target.value) || 60)}
/>
</label>
<label className="config-sync-policy-field">
{t("portTraffic.retention")}
<input
type="number"
min={1}
max={90}
value={retentionDays}
onChange={(e) => setRetentionDays(Number(e.target.value) || 7)}
/>
</label>
<label className="config-sync-policy-field">
{t("portTraffic.concurrency")}
<input
type="number"
min={1}
max={20}
value={concurrency}
onChange={(e) => setConcurrency(Number(e.target.value) || 5)}
/>
</label>
<label className="config-sync-policy-check">
<input type="checkbox" checked={startNow} onChange={(e) => setStartNow(e.target.checked)} />
{t("portTraffic.startNow")}
</label>
</div>
) : null}
{step === 2 ? (
<>
<div className="filter-inline">
<input
value={neKeyword}
onChange={(e) => {
setNeKeyword(e.target.value);
setNePage(1);
}}
placeholder={t("portTraffic.neKeywordPh")}
/>
<span className="muted">{t("portTraffic.selectedNe", { count: selectedNeList.length })}</span>
</div>
<table className="data-table">
<thead>
<tr>
<th />
<th>{t("portTraffic.col.source")}</th>
<th>{t("portTraffic.col.name")}</th>
<th>IP</th>
<th>{t("portTraffic.col.vendor")}</th>
</tr>
</thead>
<tbody>
{(nesQuery.data?.items || []).map((row) => {
const k = neKey(row.source, row.id);
return (
<tr key={k}>
<td>
<input type="checkbox" checked={Boolean(selectedNes[k])} onChange={() => toggleNe(row)} />
</td>
<td>{row.source}</td>
<td>{row.name}</td>
<td>{row.ip_address}</td>
<td>{row.vendor || "—"}</td>
</tr>
);
})}
</tbody>
</table>
</>
) : null}
{step === 3 ? (
<div className="pt-wizard__ports">
{!selectedNeList.length ? <p>{t("portTraffic.pickNeFirst")}</p> : null}
{selectedNeList.map((ne) => {
const k = neKey(ne.source, ne.id);
const bucket = portsByNe[k];
return (
<div key={k} className="pt-wizard__ne-block">
<div className="btn-row" style={{ marginBottom: 8 }}>
<strong>
{ne.name} ({ne.ip_address})
</strong>
<button type="button" disabled={bucket?.loading} onClick={() => void discoverNe(ne)}>
{bucket?.loading ? "…" : t("portTraffic.discover")}
</button>
{bucket?.error ? <span className="muted">{bucket.error}</span> : null}
</div>
{bucket?.ports?.length ? (
<table className="data-table">
<thead>
<tr>
<th />
<th>Interface</th>
<th>BW</th>
<th>Admin/Phy/Prot</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{bucket.ports.map((p) => {
const pk = portKey(ne.source === "ume" ? "ume" : "managed", ne.id, p.ifname);
return (
<tr key={pk}>
<td>
<input
type="checkbox"
checked={Boolean(pickedPorts[pk])}
onChange={() => togglePort(ne, p, bucket.meta)}
/>
</td>
<td>{p.ifname}</td>
<td>{p.bw_raw || formatBw(p.bw_bps)}</td>
<td>
{p.admin}/{p.phy}/{p.prot}
</td>
<td>{p.description || "—"}</td>
</tr>
);
})}
</tbody>
</table>
) : null}
</div>
);
})}
<p>{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</p>
</div>
) : null}
{step === 4 ? (
<div className="pt-wizard__confirm">
<p>
<strong>{title || "—"}</strong> · {intervalSec}s · {retentionDays}d · ×{concurrency}
</p>
<p>{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</p>
<ul className="pt-wizard__confirm-list">
{pickedList.slice(0, 40).map((p) => (
<li key={p.key}>
{p.ne_name} / {p.ifname} ({formatBw(p.bw_bps || 0)})
</li>
))}
{pickedList.length > 40 ? <li>… +{pickedList.length - 40}</li> : null}
</ul>
<button type="button" className="btn-primary" disabled={createMut.isPending} onClick={submitWizard}>
{t("portTraffic.confirmCreate")}
</button>
</div>
) : null}
<div className="btn-row" style={{ marginTop: 12 }}>
<button type="button" disabled={step <= 1} onClick={() => setStep((s) => s - 1)}>
{t("portTraffic.prev")}
</button>
<button type="button" disabled={step >= 4} onClick={() => setStep((s) => s + 1)}>
{t("portTraffic.next")}
</button>
</div>
</div>
) : null}
{view === "wall" ? (
<div className="pt-wall-page">
<div className="filter-inline pt-wall-page__filters">
<label>
{t("portTraffic.wallTask")}
<select
value={wallTaskId}
onChange={(e) => {
setWallTaskId(e.target.value);
setWallTargetId("");
}}
>
{(wallTasksQuery.data?.items || tasksQuery.data?.items || []).map((task) => (
<option key={task.id} value={task.id}>
{task.title}
</option>
))}
</select>
</label>
<label>
{t("portTraffic.wallPort")}
<select value={wallTargetId} onChange={(e) => setWallTargetId(e.target.value)}>
<option value="">{t("portTraffic.pickPort")}</option>
{wallTargets.map((tg) => (
<option key={tg.id} value={tg.id}>
{tg.ne_name} / {tg.ifname}
</option>
))}
</select>
</label>
<label>
{t("portTraffic.range")}
<select value={rangeHours} onChange={(e) => setRangeHours(Number(e.target.value))}>
<option value={1}>1h</option>
<option value={6}>6h</option>
<option value={24}>24h</option>
</select>
</label>
</div>
<PortTrafficWall
target={selectedWallTarget}
points={samplesQuery.data?.points || []}
rangeLabel={`${rangeHours}h`}
/>
</div>
) : null}
</section>
);
}

View file

@ -0,0 +1,157 @@
import { useEffect, useMemo, useRef } from "react";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types";
function formatBps(n: number): string {
const v = Math.abs(n);
if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`;
if (v >= 1e6) return `${(n / 1e6).toFixed(2)} M`;
if (v >= 1e3) return `${(n / 1e3).toFixed(1)} K`;
return `${n.toFixed(0)}`;
}
function formatBwLabel(bps: number): string {
if (!bps) return "—";
return `${formatBps(bps)}bit/s`;
}
type Props = {
target: PortTrafficTarget | null;
points: PortTrafficSamplePoint[];
rangeLabel: string;
};
export function PortTrafficWall({ target, points, rangeLabel }: Props) {
const rootRef = useRef<HTMLDivElement | null>(null);
const plotRef = useRef<uPlot | null>(null);
const latest = points.length ? points[points.length - 1] : null;
const kpi = useMemo(() => {
const bw = latest?.bw_bps || target?.bw_bps || 0;
return {
bw,
inBps: latest?.in_bps ?? 0,
outBps: latest?.out_bps ?? 0,
inUtil: latest?.in_util_pct ?? 0,
outUtil: latest?.out_util_pct ?? 0,
};
}, [latest, target]);
useEffect(() => {
const el = rootRef.current;
if (!el) return;
const xs = points.map((p) => Math.floor(new Date(p.ts).getTime() / 1000));
const inSeries = points.map((p) => p.in_bps);
const outSeries = points.map((p) => p.out_bps);
const opts: uPlot.Options = {
width: Math.max(320, el.clientWidth || 800),
height: Math.max(220, Math.min(420, Math.floor((el.clientWidth || 800) * 0.32))),
series: [
{},
{
label: "In",
stroke: "#2dd4bf",
width: 2,
points: { show: false },
},
{
label: "Out",
stroke: "#f59e0b",
width: 2,
points: { show: false },
},
],
axes: [
{
stroke: "#94a3b8",
grid: { stroke: "rgba(148,163,184,0.18)", width: 1 },
ticks: { stroke: "rgba(148,163,184,0.35)" },
},
{
stroke: "#94a3b8",
grid: { stroke: "rgba(148,163,184,0.12)", width: 1 },
ticks: { stroke: "rgba(148,163,184,0.35)" },
values: (_u, splits) => splits.map((v) => formatBps(v)),
size: 56,
},
],
scales: {
x: { time: true },
},
legend: { show: true },
cursor: {
drag: { x: true, y: false },
},
};
plotRef.current?.destroy();
plotRef.current = null;
if (xs.length === 0) {
el.replaceChildren();
return;
}
plotRef.current = new uPlot(opts, [xs, inSeries, outSeries], el);
const onResize = () => {
if (!plotRef.current || !rootRef.current) return;
plotRef.current.setSize({
width: Math.max(320, rootRef.current.clientWidth),
height: Math.max(220, Math.min(420, Math.floor(rootRef.current.clientWidth * 0.32))),
});
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
plotRef.current?.destroy();
plotRef.current = null;
};
}, [points]);
return (
<div className="pt-wall">
<div className="pt-wall__head">
<div className="pt-wall__title">
<span className="pt-wall__ne">{target ? target.ne_name || target.ne_ip || "—" : "—"}</span>
<span className="pt-wall__if">{target?.ifname || "Select interface"}</span>
</div>
<div className="pt-wall__range">{rangeLabel}</div>
</div>
<div className="pt-wall__kpis">
<div className="pt-wall__kpi">
<div className="pt-wall__kpi-label">Bandwidth</div>
<div className="pt-wall__kpi-value">{formatBwLabel(kpi.bw)}</div>
</div>
<div className="pt-wall__kpi pt-wall__kpi--in">
<div className="pt-wall__kpi-label">In</div>
<div className="pt-wall__kpi-value">
{formatBps(kpi.inBps)}
<span className="pt-wall__kpi-unit">bit/s</span>
</div>
</div>
<div className="pt-wall__kpi pt-wall__kpi--out">
<div className="pt-wall__kpi-label">Out</div>
<div className="pt-wall__kpi-value">
{formatBps(kpi.outBps)}
<span className="pt-wall__kpi-unit">bit/s</span>
</div>
</div>
<div className="pt-wall__kpi">
<div className="pt-wall__kpi-label">Util In / Out</div>
<div className="pt-wall__kpi-value">
{kpi.inUtil.toFixed(1)}%
<span className="pt-wall__kpi-sep">/</span>
{kpi.outUtil.toFixed(1)}%
</div>
</div>
</div>
<div className="pt-wall__chart" ref={rootRef}>
{!points.length ? <div className="pt-wall__empty">No samples in range</div> : null}
</div>
</div>
);
}

View file

@ -29,6 +29,12 @@ import type {
ConfigSyncTask,
NeConfigSnapshotDetail,
NeConfigSnapshotMeta,
PortTrafficDashboard,
PortTrafficDiscoverResponse,
PortTrafficSamples,
PortTrafficTarget,
PortTrafficTargetIn,
PortTrafficTask,
} from "../types";
export const AUTH_TOKEN_KEY = "netx_access_token";
@ -779,3 +785,56 @@ export const downloadNeConfigSnapshot = async (
URL.revokeObjectURL(url);
}
};
export const fetchPortTrafficDashboard = () =>
apiGet<PortTrafficDashboard>("/v1/port-traffic/dashboard");
export const fetchPortTrafficTasks = (params: { page?: number; pageSize?: number }) => {
const p = new URLSearchParams();
p.set("page", String(Math.max(1, Number(params.page || 1))));
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficTask[] }>(
`/v1/port-traffic/tasks?${p.toString()}`,
);
};
export const createPortTrafficTask = (body: {
title: string;
interval_sec?: number;
retention_days?: number;
concurrency?: number;
targets?: PortTrafficTargetIn[];
start_now?: boolean;
}) => apiPost<PortTrafficTask>("/v1/port-traffic/tasks", body);
export const startPortTrafficTask = (taskId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/start`, {});
export const pausePortTrafficTask = (taskId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/pause`, {});
export const stopPortTrafficTask = (taskId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/stop`, {});
export const deletePortTrafficTask = (taskId: string) =>
apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}`);
export const fetchPortTrafficTargets = (taskId: string) =>
apiGet<{ items: PortTrafficTarget[] }>(
`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/targets`,
);
export const discoverPortTrafficPorts = (body: { source: "managed" | "ume"; id: string }) =>
apiPost<PortTrafficDiscoverResponse>("/v1/port-traffic/discover/ports", body);
export const fetchPortTrafficSamples = (params: {
targetId: string;
from?: string;
to?: string;
}) => {
const p = new URLSearchParams();
p.set("target_id", params.targetId);
if (params.from) p.set("from", params.from);
if (params.to) p.set("to", params.to);
return apiGet<PortTrafficSamples>(`/v1/port-traffic/samples?${p.toString()}`);
};

View file

@ -507,3 +507,93 @@ export type NeConfigSnapshotDetail = NeConfigSnapshotMeta & {
config_alt_text: string;
};
export type PortTrafficTask = {
id: string;
title: string;
status: string;
interval_sec: number;
retention_days: number;
concurrency: number;
collect_running: boolean;
target_count: number;
active_target_count: number;
last_collect_started_at?: string | null;
last_collect_ended_at?: string | null;
last_error: string;
created_at?: string | null;
updated_at?: string | null;
};
export type PortTrafficTarget = {
id: string;
task_id: string;
source: string;
target_id: string;
ne_name: string;
ne_ip: string;
vendor: string;
ifname: string;
if_description: string;
bw_bps: number;
status: string;
last_error: string;
last_sample_at?: string | null;
created_at?: string | null;
};
export type PortTrafficTargetIn = {
source: "managed" | "ume";
target_id: string;
ne_name?: string;
ne_ip?: string;
vendor?: string;
ifname: string;
if_description?: string;
bw_bps?: number;
};
export type PortTrafficDiscoverPort = {
ifname: string;
attribute: string;
mode: string;
bw_raw: string;
bw_bps: number;
admin: string;
phy: string;
prot: string;
description: string;
};
export type PortTrafficDiscoverResponse = {
source: string;
id: string;
ne_name: string;
ne_ip: string;
vendor: string;
vendor_key: string;
ports: PortTrafficDiscoverPort[];
};
export type PortTrafficSamplePoint = {
ts: string;
in_bps: number;
out_bps: number;
in_util_pct: number;
out_util_pct: number;
bw_bps: number;
rate_period_sec: number;
};
export type PortTrafficSamples = {
target: PortTrafficTarget;
points: PortTrafficSamplePoint[];
};
export type PortTrafficDashboard = {
task_count: number;
running_task_count: number;
active_target_count: number;
sample_count_24h: number;
last_sample_at?: string | null;
};