mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 06:40:45 +08:00
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:
parent
a95c6598bb
commit
efe8f58cd2
25 changed files with 2880 additions and 36 deletions
11
web/WEB.md
11
web/WEB.md
|
|
@ -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
9
web/package-lock.json
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
645
web/src/pages/network/PortTrafficPage.tsx
Normal file
645
web/src/pages/network/PortTrafficPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
web/src/pages/network/PortTrafficWall.tsx
Normal file
157
web/src/pages/network/PortTrafficWall.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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()}`);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue