Move port traffic to device-centric monitoring with a dedicated wall and collect logs.

Split the ops wall to its own route with fullscreen charts, persist collect errors as device events, and polish wall legend layout.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 15:45:20 +08:00
parent 5079343b2a
commit 214bc56774
24 changed files with 3309 additions and 1357 deletions

View file

@ -42,7 +42,8 @@ src/
| `/network/topology` | redirect → `/topology` | — |
| `/network/tasks/collect` | 采集任务 | `network` |
| `/network/tasks/config-sync` | 配置同步 | `network` |
| `/network/tasks/port-traffic` | 端口流量监控(任务向导 + 大屏) | `network` |
| `/network/tasks/port-traffic` | 端口流量监控(设备管理) | `network` |
| `/network/tasks/port-traffic/wall` | 端口流量大屏 | `network` |
| `/topology` | 拓扑管理(模式化编辑器:选择/平移/拖动/连线、框选、自动布局、拖放添加) | `topology` |
| `/webcrt` | WebCRT 终端 | `webcrt` |
| `/collect` | redirect → `/network/tasks/collect` | — |
@ -107,15 +108,16 @@ src/
## 端口流量监控
- API:`/v1/port-traffic/*`(任务 CRUD、discover/ports、samples、compare、dashboard)
- 接口:大屏与取样均按 `port_traffic_target`(网元 + ifname,含各类接口);样点按 `target_row_id`
- 周期对比:`GET /v1/port-traffic/compare?target_id=&range_hours=&baseline=off|shift|day|week|custom`(同一接口时间平移叠图)
- 手工映射:可选 `baseline_target_id`(可跨任务),基线取自另一个接口;可与周期偏移叠加
- 厂商:ZTE / 华为 / 思科;解析速率 **bit/s**
- 调度:`NETX_PORT_TRAFFIC_SCHEDULER_ENABLED`(默认开),tick `NETX_PORT_TRAFFIC_SCHEDULER_TICK_SEC`(默认 15)
- 单飞:同一任务同时只允许一轮采集;崩溃启动清除 `collect_running`
- 保留:按任务 `retention_days` 清理过期 sample(周对比建议 ≥8 天)
- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 大屏;接口选择 + 周期对比 + 跨任务映射基线接口)
- API:`/v1/port-traffic/devices/*`(设备 CRUD、interfaces、samples、compare、dashboard);`discover/ports` 仍按网元拉取接口
- **设备中心**:每台网元(`source`+`ne_id`)一份监控配置;周期/保留/启停挂在设备上;接口归属于该设备
- 唯一性:同一物理口 `(source, ne_id, ifname)` 全局仅允许一个 **active** 监控行,避免重复 CLI 采集
- 调度:按设备到期;一轮采集对该设备 **登录一次** 后批量 `show interface`
- 手工映射:大屏可选其它设备接口作基线叠图;可与周期偏移叠加
- 厂商:ZTE / 华为 / 思科;解析速率 **bit/s**;缺厂商 util 时按 bps/bw 回算
- 调度开关:`NETX_PORT_TRAFFIC_SCHEDULER_ENABLED`(默认开),tick `NETX_PORT_TRAFFIC_SCHEDULER_TICK_SEC`(默认 15)
- 保留:按设备 `retention_days` 清理过期 sample(周对比建议 ≥8 天)
- 前端:`/network/tasks/port-traffic`(设备列表 / 向导 / 编辑 / 采集日志);`/network/tasks/port-traffic/wall`(独立 uPlot 大屏,支持 `?device_id=&target_id=`)
- 采集日志:`GET /v1/port-traffic/devices/{id}/events`;失败写入 `port_traffic_event`,列表操作可查看
- 支持拓扑深链:`?ne_id=&source=managed|ume&ifname=` 打开向导并预填网元
## 拓扑管理

View file

@ -14,6 +14,7 @@ import { NetworkDevicesPage } from "./pages/network/NetworkDevicesPage";
import { NetworkAlarmsPage } from "./pages/network/NetworkAlarmsPage";
import { NetworkConfigsPage } from "./pages/network/NetworkConfigsPage";
import { PortTrafficPage } from "./pages/network/PortTrafficPage";
import { PortTrafficWallPage } from "./pages/network/PortTrafficWallPage";
import { LoginPage } from "./pages/LoginPage";
import { UsersPage } from "./pages/UsersPage";
import { AuditPage } from "./pages/AuditPage";
@ -95,6 +96,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/wall" element={<PortTrafficWallPage />} />
<Route path="tasks/port-traffic" element={<PortTrafficPage />} />
</Route>
<Route path="/collect" element={<Navigate to="/network/tasks/collect" replace />} />

View file

@ -50,6 +50,12 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [
labelKey: "network.nav.portTraffic",
group: "tasks",
},
{
id: "port-traffic-wall",
path: "/network/tasks/port-traffic/wall",
labelKey: "network.nav.portTrafficWall",
group: "tasks",
},
],
},
] as const;

View file

@ -60,9 +60,10 @@ export const queryKeys = {
["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,
portTrafficDevicesAll: ["portTrafficDevices"] as const,
portTrafficDevices: (page: number) => ["portTrafficDevices", page] as const,
portTrafficTargets: (deviceId: string) => ["portTrafficTargets", deviceId] as const,
portTrafficEvents: (deviceId: string) => ["portTrafficEvents", deviceId] as const,
portTrafficSamples: (targetId: string, rangeHours: number) =>
["portTrafficSamples", targetId, rangeHours] as const,
portTrafficCompare: (

View file

@ -54,6 +54,7 @@ const en = {
collectTasks: "Collection tasks",
configSync: "Config sync",
portTraffic: "Port traffic",
portTrafficWall: "Traffic wall",
},
collapseNav: "Collapse sidebar",
expandNav: "Expand sidebar",
@ -64,21 +65,37 @@ const en = {
},
portTraffic: {
title: "Port traffic",
create: "New task",
create: "Monitor device",
backList: "Back to list",
created: "Task created",
created: "Device monitoring added",
createFailed: "Create failed",
updated: "Saved",
interfacesSaved: "Interfaces updated",
started: "Started",
paused: "Paused",
stopped: "Stopped",
statusRunning: "Running",
statusPaused: "Paused",
statusStopped: "Stopped",
collecting: "Collecting",
deleted: "Deleted",
confirmDelete: "Delete this task and its samples?",
empty: "No monitoring tasks yet. Create one to start.",
confirmDelete: "Delete this device monitoring and its samples?",
empty: "No devices monitored yet. Pick a NE and interfaces to start.",
wall: "Ops wall",
wallTitle: "Port traffic wall",
toDevices: "Manage devices",
wallEmpty: "No monitored devices yet. Add a device and pick interfaces first.",
fullscreen: "Fullscreen",
exitFullscreen: "Exit fullscreen",
edit: "Edit",
save: "Save",
start: "Start",
pause: "Pause",
stop: "Stop",
delete: "Delete",
wizStep1: "Device",
wizStep2: "Interfaces",
wizStep3: "Params",
step1: "Params",
step2: "NEs",
step3: "Interfaces",
@ -93,18 +110,22 @@ const en = {
retention: "Retention (days)",
retentionHintWizard: "Use ≥8 days for week-over-week compare",
concurrency: "Concurrency",
note: "Note",
notePh: "Optional",
startNow: "Start after create",
neKeywordPh: "Name / IP / vendor",
deepLinkHint: "Opened from topology: NE {{ne}}{{ifname}}. Confirm selection, then continue.",
deepLinkIfname: ", interface {{ifname}}",
selectedNe: "{{count}} NE(s) selected",
selectedOneNe: "Selected",
selectedPorts: "{{count}} interface(s) selected",
pickNeFirst: "Select NEs in the previous step first",
pickNeFirst: "Select one NE first",
discover: "Discover interfaces",
confirmCreate: "Create task",
confirmCreate: "Start monitoring",
wallTask: "Task",
wallPort: "Interface",
wallDevice: "Device",
wallDeviceLabel: "Device",
pickPort: "Select interface",
range: "Range",
compare: "Compare",
@ -130,21 +151,38 @@ const en = {
collectStarted: "Collect triggered",
collectBusy: "Collect already running",
targetError: "Last interface error",
waitSamples: "Samples appear after the task runs; or click Collect now. Chart auto-refreshes.",
waitAfterError: "Last collect failed — fix connectivity, then Collect now.",
waitSamples: "Samples appear after the device runs; or click Collect now. Chart auto-refreshes.",
waitAfterError: "Collect failed — open Logs under Traffic monitoring for details, then Collect now.",
log: "Logs",
logTitle: "Collect logs",
logEmpty: "No log entries yet.",
logDevice: "Device",
logLevel: "Level",
logTime: "Time",
logMessage: "Message",
logClose: "Close",
logHasError: "Has error",
kpiLatest: "latest",
kpiBw: "Bandwidth",
latestAt: "Latest sample",
hoverPoint: "Cursor",
wallLiveTitle: "Live traffic",
filterView: "View",
filterMap: "Mapped baseline",
yMode: "Y axis",
yModeAuto: "Auto (incl. baseline)",
yModeCurrent: "Fit current",
yModeUtil: "Utilization %",
kpi: {
tasks: "Tasks",
devices: "Devices",
running: "Running",
ports: "Interfaces",
samples24h: "Samples 24h",
},
col: {
title: "Task",
device: "Device",
status: "Status",
ports: "Interfaces",
interval: "Interval",

View file

@ -53,7 +53,8 @@ const zh = {
webcrt: "WebCRT",
collectTasks: "采集任务",
configSync: "配置同步",
portTraffic: "端口流量监控",
portTraffic: "流量监控",
portTrafficWall: "流量大屏",
},
collapseNav: "折叠侧栏",
expandNav: "展开侧栏",
@ -64,21 +65,37 @@ const zh = {
},
portTraffic: {
title: "端口流量监控",
create: "新建任务",
create: "添加设备监控",
backList: "返回列表",
created: "任务已创建",
created: "已添加设备监控",
createFailed: "创建失败",
updated: "已保存",
interfacesSaved: "接口已更新",
started: "已启动",
paused: "已暂停",
stopped: "已停止",
statusRunning: "运行中",
statusPaused: "已暂停",
statusStopped: "已停止",
collecting: "采集中",
deleted: "已删除",
confirmDelete: "删除该任务及其采样数据?",
empty: "暂无监控任务。请先创建任务。",
confirmDelete: "删除该设备监控及其采样数据?",
empty: "暂无设备监控。请先选择网元并勾选接口。",
wall: "监控大屏",
wallTitle: "端口流量大屏",
toDevices: "设备管理",
wallEmpty: "暂无监控设备。请先添加设备并勾选接口。",
fullscreen: "全屏",
exitFullscreen: "退出全屏",
edit: "编辑",
save: "保存",
start: "启动",
pause: "暂停",
stop: "停止",
delete: "删除",
wizStep1: "选设备",
wizStep2: "选接口",
wizStep3: "参数",
step1: "参数",
step2: "选网元",
step3: "选接口",
@ -93,18 +110,22 @@ const zh = {
retention: "保留(天)",
retentionHintWizard: "若需上周同日对比,建议 ≥8 天",
concurrency: "并发",
note: "备注",
notePh: "可选",
startNow: "创建后立即启动",
neKeywordPh: "名称 / IP / 厂商",
deepLinkHint: "已从拓扑跳转:网元 {{ne}}{{ifname}}。请确认选中后继续创建监控任务。",
deepLinkHint: "已从拓扑跳转:网元 {{ne}}{{ifname}}。请确认选中后继续。",
deepLinkIfname: ",接口 {{ifname}}",
selectedNe: "已选 {{count}} 台网元",
selectedOneNe: "已选",
selectedPorts: "已选 {{count}} 个接口",
pickNeFirst: "请先在上一步选择网元",
pickNeFirst: "请先选择一台网元",
discover: "拉取接口",
confirmCreate: "创建任务",
confirmCreate: "开始监控",
wallTask: "任务",
wallDevice: "设备",
wallPort: "接口",
wallDevice: "网元",
wallDeviceLabel: "网元",
pickPort: "选择接口",
range: "时间范围",
compare: "周期对比",
@ -125,26 +146,43 @@ const zh = {
mapBaselinePort: "映射基线接口",
mapBaselineTask: "映射基线任务",
mapBaselineNone: "不映射",
retentionHint: "周对比建议任务保留天数 ≥ 8;否则基线可能无数据。",
retentionHint: "周对比建议保留天数 ≥ 8;否则基线可能无数据。",
collectNow: "立即采集",
collectStarted: "已触发采集",
collectBusy: "采集进行中",
targetError: "接口最近错误",
waitSamples: "任务运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。",
waitAfterError: "采集失败,请检查连通性后点「立即采集」重试。",
waitSamples: "设备运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。",
waitAfterError: "采集失败,请到「流量监控」操作中的日志查看详情,修复后可点「立即采集」重试。",
log: "日志",
logTitle: "采集日志",
logEmpty: "暂无日志。",
logDevice: "设备",
logLevel: "级别",
logTime: "时间",
logMessage: "内容",
logClose: "关闭",
logHasError: "有错误",
kpiLatest: "最新",
kpiBw: "带宽",
latestAt: "最新样点",
hoverPoint: "光标位置",
wallLiveTitle: "实时流量",
filterView: "视图",
filterMap: "映射基线",
yMode: "纵轴",
yModeAuto: "自动(含基线)",
yModeCurrent: "按当前流量",
yModeUtil: "利用率 %",
kpi: {
tasks: "任务数",
devices: "监控设备",
running: "运行中",
ports: "监控接口",
samples24h: "24h 样点",
},
col: {
title: "任务",
device: "设备",
status: "状态",
ports: "接口",
interval: "周期",

View file

@ -3695,7 +3695,8 @@ pre {
}
.network-main:has(.topo-page),
.network-main:has(.webcrt-shell) {
.network-main:has(.webcrt-shell),
.network-main:has(.pt-wall-shell) {
padding: 0;
overflow: hidden;
}
@ -3714,6 +3715,12 @@ pre {
border: 0;
}
.network-main .pt-wall-shell {
height: 100%;
margin: 0;
min-height: 0;
}
.network-placeholder h2 {
margin: 0 0 8px;
}
@ -3763,58 +3770,578 @@ pre {
color: #334155;
}
.pt-wall-page__filters {
margin-bottom: 12px;
gap: 12px 18px;
align-items: flex-end;
.pt-list {
display: flex;
flex-direction: column;
gap: 14px;
}
.pt-wall-page__filters label {
display: inline-flex;
.pt-list-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
}
.pt-list-kpi {
padding: 12px 14px;
border: 1px solid #e2e8f0;
border-radius: 8px;
background: #f8fafc;
}
.pt-list-kpi--live {
background: #eff6ff;
border-color: #bfdbfe;
}
.pt-list-kpi__label {
font-size: 12px;
color: #64748b;
margin-bottom: 4px;
}
.pt-list-kpi__value {
font-size: 22px;
font-weight: 650;
font-variant-numeric: tabular-nums;
color: #0f172a;
line-height: 1.2;
}
.pt-list-kpi--live .pt-list-kpi__value {
color: #1d4ed8;
}
.pt-list-table-wrap {
border: 1px solid #e2e8f0;
border-radius: 8px;
overflow: auto;
}
.pt-list-table {
margin: 0;
}
.pt-list-table thead th {
background: #f8fafc;
font-size: 12px;
font-weight: 600;
color: #64748b;
white-space: nowrap;
}
.pt-list-table tbody td {
vertical-align: middle;
}
.pt-list-table tbody tr:hover td {
background: #f8fafc;
}
.pt-list-task-name {
font-weight: 600;
color: #0f172a;
}
.pt-list-task-err {
margin-top: 2px;
max-width: 280px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 12px;
color: #b91c1c;
}
.pt-list-task-err-btn {
display: inline-block;
margin-top: 4px;
padding: 0;
border: 0;
background: transparent;
color: #b91c1c;
font-size: 12px;
font-weight: 600;
cursor: pointer;
text-decoration: underline;
text-underline-offset: 2px;
}
.pt-list-task-err-btn:hover {
color: #991b1b;
}
.pt-log-modal__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
margin-bottom: 12px;
}
.pt-log-modal__head h3 {
margin: 0;
}
.pt-log-modal__sub {
margin: 4px 0 0;
font-size: 13px;
}
.pt-log-modal__list {
display: flex;
flex-direction: column;
gap: 4px;
gap: 10px;
max-height: min(60vh, 520px);
overflow: auto;
}
.pt-log-item {
border: 1px solid #e2e8f0;
border-radius: 8px;
padding: 10px 12px;
background: #f8fafc;
}
.pt-log-item--error {
border-color: #fecaca;
background: #fef2f2;
}
.pt-log-item--warn {
border-color: #fde68a;
background: #fffbeb;
}
.pt-log-item__meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 12px;
margin-bottom: 6px;
font-size: 12px;
color: #64748b;
}
.pt-wall-page__filters select {
min-width: 160px;
.pt-log-item__level {
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 700;
color: #b91c1c;
}
.pt-log-item--warn .pt-log-item__level {
color: #b45309;
}
.pt-log-item--info .pt-log-item__level {
color: #0369a1;
}
.pt-log-item__if {
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
color: #0f172a;
}
.pt-log-item__msg {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-size: 12px;
line-height: 1.45;
color: #1e293b;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
}
.pt-list-status {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 2px 8px;
border-radius: 999px;
font-size: 12px;
font-weight: 600;
line-height: 1.5;
white-space: nowrap;
}
.pt-list-status + .pt-list-status {
margin-left: 6px;
}
.pt-list-status--running {
background: #dcfce7;
color: #166534;
}
.pt-list-status--running::before,
.pt-list-status--collect::before {
content: "";
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.pt-list-status--paused {
background: #fef9c3;
color: #854d0e;
}
.pt-list-status--stopped,
.pt-list-status--other {
background: #f1f5f9;
color: #475569;
}
.pt-list-status--collect {
background: #e0e7ff;
color: #3730a3;
}
.pt-list-num,
.pt-list-time {
font-variant-numeric: tabular-nums;
white-space: nowrap;
color: #334155;
}
.pt-list-actions {
display: flex;
flex-wrap: wrap;
gap: 6px;
align-items: center;
}
.pt-list-actions .btn-primary,
.pt-list-actions .btn--danger,
.pt-list-actions button {
padding: 4px 10px;
font-size: 12px;
line-height: 1.4;
}
.pt-list-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
min-height: 200px;
padding: 32px 16px;
border: 1px dashed #cbd5e1;
border-radius: 8px;
background: #f8fafc;
color: #64748b;
text-align: center;
}
.pt-list-empty p {
margin: 0;
}
.pt-list-pager {
justify-content: center;
}
@media (max-width: 900px) {
.pt-list-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.pt-wall-shell {
display: flex;
flex-direction: column;
min-height: 0;
color: #e2e8f0;
background:
radial-gradient(1200px 480px at 12% -10%, rgba(45, 212, 191, 0.08), transparent 55%),
radial-gradient(900px 420px at 88% 0%, rgba(56, 189, 248, 0.06), transparent 50%),
linear-gradient(180deg, #0a1220 0%, #070d18 100%);
}
.pt-wall-shell__empty {
flex: 1;
display: grid;
place-content: center;
gap: 12px;
color: #94a3b8;
}
.pt-wall-page {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
padding: 10px 14px 12px;
gap: 0;
}
.pt-wall-page__hint {
margin: 0 0 8px;
flex: 0 0 auto;
font-size: 12px;
color: #94a3b8;
}
.pt-wall-page__stage {
position: relative;
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.pt-wall-page__stage:fullscreen,
.pt-wall-page__stage.is-fullscreen:fullscreen {
width: 100vw;
height: 100vh;
padding: 20px 24px;
box-sizing: border-box;
background:
radial-gradient(1200px 480px at 12% -10%, rgba(45, 212, 191, 0.08), transparent 55%),
linear-gradient(180deg, #0a1220 0%, #070d18 100%);
}
.pt-wall-page__stage .pt-wall {
flex: 1;
min-height: 0;
height: 100%;
}
.pt-wall-page__fs-btn {
display: inline-flex;
align-items: center;
gap: 6px;
height: 30px;
padding: 0 11px;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 6px;
background: transparent;
color: #cbd5e1;
font-size: 12px;
cursor: pointer;
}
.pt-wall-page__fs-btn--toolbar {
flex: 0 0 auto;
align-self: flex-end;
}
.pt-wall-page__fs-btn:hover {
background: rgba(148, 163, 184, 0.12);
border-color: rgba(148, 163, 184, 0.45);
color: #f8fafc;
}
.app-main .pt-wall-page__fs-btn {
background: transparent;
color: #cbd5e1;
}
.app-main .pt-wall-page__fs-btn:hover {
background: rgba(148, 163, 184, 0.12);
color: #f8fafc;
}
.pt-wall-page__toolbar {
display: flex;
flex-direction: column;
gap: 8px;
flex: 0 0 auto;
margin-bottom: 10px;
padding: 0 0 10px;
background: transparent;
border: 0;
border-bottom: 1px solid rgba(148, 163, 184, 0.14);
border-radius: 0;
}
.pt-wall-page__row {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px 10px;
}
.pt-wall-page__row--primary {
padding-bottom: 0;
border-bottom: 0;
}
.pt-wall-page__row--secondary {
align-items: flex-end;
}
.pt-wall-page__field {
display: flex;
flex-direction: column;
gap: 3px;
font-size: 11px;
color: #64748b;
min-width: 0;
}
.pt-wall-page__field select,
.pt-wall-page__field input {
height: 30px;
padding: 0 9px;
border: 1px solid rgba(148, 163, 184, 0.22);
border-radius: 6px;
background: rgba(15, 23, 42, 0.55);
color: #e2e8f0;
font-size: 12px;
}
.pt-wall-page__field select:hover,
.pt-wall-page__field input:hover,
.pt-wall-page__field select:focus,
.pt-wall-page__field input:focus {
border-color: rgba(45, 212, 191, 0.45);
outline: none;
}
.app-main .pt-wall-page__field select,
.app-main .pt-wall-page__field input {
background: rgba(15, 23, 42, 0.55);
color: #e2e8f0;
border-color: rgba(148, 163, 184, 0.22);
}
.pt-wall-page__field--task {
flex: 0 1 160px;
min-width: 128px;
}
.pt-wall-page__field--port {
flex: 1 1 220px;
min-width: 180px;
}
.pt-wall-page__field--sm {
flex: 0 0 78px;
}
.pt-wall-page__field--md {
flex: 0 1 148px;
min-width: 120px;
}
.pt-wall-page__field--port select {
width: 100%;
}
.pt-wall-page__collect {
flex: 0 0 auto;
height: 30px;
margin-left: auto;
align-self: flex-end;
white-space: nowrap;
border-radius: 6px;
border: 1px solid rgba(45, 212, 191, 0.35);
background: rgba(45, 212, 191, 0.16);
color: #99f6e4;
font-size: 12px;
}
.app-main .pt-wall-page__collect.btn-primary {
background: rgba(45, 212, 191, 0.16);
border-color: rgba(45, 212, 191, 0.35);
color: #99f6e4;
}
.app-main .pt-wall-page__collect.btn-primary:hover:not(:disabled) {
background: rgba(45, 212, 191, 0.28);
color: #ecfdf5;
}
.pt-wall-page__cluster {
display: flex;
flex-direction: row;
align-items: flex-end;
gap: 8px 10px;
min-width: 0;
}
.pt-wall-page__cluster--map {
flex: 1 1 280px;
}
.pt-wall-page__cluster-label {
flex: 0 0 auto;
padding-bottom: 7px;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.08em;
text-transform: uppercase;
color: #475569;
white-space: nowrap;
}
.pt-wall-page__cluster-fields {
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px 10px;
}
.pt-wall-page__cluster-sep {
width: 1px;
align-self: stretch;
margin: 4px 6px 0;
background: rgba(148, 163, 184, 0.14);
flex: 0 0 1px;
}
@media (max-width: 900px) {
.pt-wall-page__cluster {
flex-direction: column;
align-items: stretch;
}
.pt-wall-page__cluster-label {
padding-bottom: 0;
}
.pt-wall-page__cluster-sep {
display: none;
}
.pt-wall-page__collect {
margin-left: 0;
}
.pt-wall-page__field--port {
flex-basis: 100%;
}
}
.pt-wall {
--pt-bg: #0b1220;
--pt-panel: #111827;
--pt-line: rgba(148, 163, 184, 0.22);
--pt-bg: #0a1220;
--pt-panel: transparent;
--pt-line: rgba(148, 163, 184, 0.16);
--pt-text: #e2e8f0;
--pt-muted: #94a3b8;
--pt-muted: #8b9bb0;
--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;
--pt-out: #fbbf24;
display: flex;
flex-direction: column;
min-height: 0;
background: transparent;
border: 0;
border-radius: 0;
padding: 4px 2px 0;
color: var(--pt-text);
}
.pt-wall__head {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 12px;
margin-bottom: 14px;
align-items: flex-end;
gap: 16px;
margin-bottom: 12px;
flex: 0 0 auto;
}
.pt-wall__head-title {
@ -3832,51 +4359,71 @@ pre {
}
.pt-wall__ne {
font-size: 13px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.02em;
color: var(--pt-muted);
}
.pt-wall__if {
font-size: 20px;
font-weight: 650;
letter-spacing: 0.02em;
font-size: 22px;
font-weight: 600;
letter-spacing: 0.01em;
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
color: #f8fafc;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: min(720px, 72vw);
}
.pt-wall__range {
font-size: 12px;
color: var(--pt-muted);
white-space: nowrap;
padding-bottom: 3px;
font-variant-numeric: tabular-nums;
}
.pt-wall__kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 10px;
gap: 0;
margin-bottom: 14px;
flex: 0 0 auto;
border: 1px solid rgba(148, 163, 184, 0.12);
border-radius: 8px;
background: rgba(15, 23, 42, 0.35);
overflow: hidden;
}
.pt-wall__kpi {
background: rgba(17, 24, 39, 0.72);
border: 1px solid var(--pt-line);
border-radius: 8px;
padding: 10px 12px;
background: transparent;
border: 0;
border-radius: 0;
border-right: 1px solid rgba(148, 163, 184, 0.1);
padding: 12px 16px;
}
.pt-wall__kpi:last-child {
border-right: 0;
}
.pt-wall__kpi-label {
font-size: 11px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--pt-muted);
margin-bottom: 4px;
letter-spacing: 0.08em;
color: #64748b;
margin-bottom: 6px;
}
.pt-wall__kpi-value {
font-size: 22px;
font-weight: 650;
font-size: 20px;
font-weight: 600;
font-variant-numeric: tabular-nums;
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
line-height: 1.2;
line-height: 1.15;
color: #f1f5f9;
}
.pt-wall__kpi--in .pt-wall__kpi-value {
@ -3887,16 +4434,33 @@ pre {
color: var(--pt-out);
}
.pt-wall__kpi--util .pt-wall__kpi-value {
color: #7dd3fc;
}
.pt-wall__kpi-unit,
.pt-wall__kpi-sep {
margin-left: 4px;
font-size: 12px;
font-size: 11px;
color: var(--pt-muted);
font-weight: 500;
}
.pt-wall__chart-wrap {
position: relative;
flex: 1;
min-height: 180px;
width: 100%;
border-radius: 8px;
background: rgba(8, 14, 26, 0.45);
border: 1px solid rgba(148, 163, 184, 0.1);
padding: 8px 8px 4px;
box-sizing: border-box;
}
.pt-wall__chart {
min-height: 260px;
min-height: 0;
height: 100%;
width: 100%;
}
@ -3905,12 +4469,6 @@ pre {
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
}
.pt-wall__chart-wrap {
position: relative;
min-height: 260px;
width: 100%;
}
.pt-wall__tip {
position: absolute;
top: 0;
@ -3919,13 +4477,14 @@ pre {
min-width: 150px;
padding: 8px 10px;
border-radius: 8px;
border: 1px solid rgba(148, 163, 184, 0.35);
background: rgba(15, 23, 42, 0.94);
border: 1px solid rgba(148, 163, 184, 0.28);
background: rgba(8, 14, 26, 0.94);
color: #e2e8f0;
font-size: 12px;
line-height: 1.45;
pointer-events: none;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
box-shadow: 0 10px 28px rgba(0, 0, 0, 0.4);
backdrop-filter: blur(6px);
}
.pt-wall__tip-time {
@ -3946,45 +4505,99 @@ pre {
}
.pt-wall__tip-row--out strong {
color: #f59e0b;
color: #fbbf24;
}
.pt-wall__chart .u-axis {
border: 0 !important;
}
.pt-wall__chart .u-legend {
color: var(--pt-muted);
}
.pt-wall__chart .u-legend .u-value {
display: none;
display: none !important;
}
.pt-wall__foot {
display: flex;
flex-wrap: wrap;
gap: 10px 28px;
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(148, 163, 184, 0.18);
align-items: baseline;
align-items: center;
justify-content: space-between;
gap: 8px 20px;
margin-top: 8px;
padding-top: 0;
border-top: 0;
flex: 0 0 auto;
min-height: 28px;
}
.pt-wall__legend {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 16px;
color: var(--pt-muted);
font-size: 12px;
line-height: 1.2;
}
.pt-wall__legend-item {
display: inline-flex;
align-items: center;
gap: 6px;
white-space: nowrap;
}
.pt-wall__legend-swatch {
display: inline-block;
width: 10px;
height: 10px;
border-radius: 2px;
flex: 0 0 10px;
}
.pt-wall__legend-item--in .pt-wall__legend-swatch {
background: #2dd4bf;
}
.pt-wall__legend-item--out .pt-wall__legend-swatch {
background: #fbbf24;
}
.pt-wall__legend-item--base-in .pt-wall__legend-swatch {
background: rgba(45, 212, 191, 0.45);
box-shadow: inset 0 0 0 1px rgba(45, 212, 191, 0.7);
}
.pt-wall__legend-item--base-out .pt-wall__legend-swatch {
background: rgba(251, 191, 36, 0.4);
box-shadow: inset 0 0 0 1px rgba(251, 191, 36, 0.65);
}
.pt-wall__foot-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px 20px;
margin-left: auto;
}
.pt-wall__foot-ne,
.pt-wall__foot-if {
display: flex;
flex-wrap: wrap;
align-items: baseline;
align-items: center;
gap: 6px 10px;
min-width: 0;
}
.pt-wall__foot-label {
font-size: 11px;
font-size: 10px;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--pt-muted);
letter-spacing: 0.08em;
color: #64748b;
}
.pt-wall__foot-value {
font-size: 15px;
font-size: 13px;
font-weight: 600;
color: #e2e8f0;
}
@ -4002,7 +4615,7 @@ pre {
.pt-wall__empty {
position: absolute;
inset: 0;
inset: 8px;
display: flex;
align-items: center;
justify-content: center;
@ -4010,9 +4623,9 @@ pre {
text-align: center;
line-height: 1.5;
color: var(--pt-muted);
background: rgba(11, 18, 32, 0.72);
border: 1px dashed rgba(148, 163, 184, 0.28);
border-radius: 8px;
background: rgba(8, 14, 26, 0.55);
border: 1px dashed rgba(148, 163, 184, 0.22);
border-radius: 6px;
pointer-events: none;
}
@ -4020,4 +4633,12 @@ pre {
.pt-wall__kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pt-wall__kpi:nth-child(2n) {
border-right: 0;
}
.pt-wall__kpi:nth-child(-n + 2) {
border-bottom: 1px solid rgba(148, 163, 184, 0.1);
}
}

View file

@ -104,7 +104,10 @@ export function NetworkLayout() {
className={({ isActive }) =>
`network-nav__link${isActive ? " is-active" : ""}`
}
end={item.path === "/network/devices"}
end={
item.path === "/network/devices" ||
item.path === "/network/tasks/port-traffic"
}
>
{t(item.labelKey)}
</NavLink>
@ -127,7 +130,10 @@ export function NetworkLayout() {
className={({ isActive }) =>
`network-nav__rail-link${isActive ? " is-active" : ""}`
}
end={item.path === "/network/devices"}
end={
item.path === "/network/devices" ||
item.path === "/network/tasks/port-traffic"
}
>
{t(item.labelKey).slice(0, 1)}
</NavLink>

File diff suppressed because it is too large Load diff

View file

@ -5,6 +5,8 @@ import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types";
import { formatSystemTime, parseApiTime } from "../../utils/time";
import { useI18n } from "../../i18n";
export type WallYMode = "auto" | "current" | "util";
function formatBps(n: number): string {
const v = Math.abs(n);
if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`;
@ -18,13 +20,59 @@ function formatBwLabel(bps: number): string {
return `${formatBps(bps)}bit/s`;
}
function formatUtilPct(n: number): string {
if (!Number.isFinite(n) || n <= 0) return "0%";
if (n < 0.01) return "<0.01%";
if (n < 1) return `${n.toFixed(2)}%`;
return `${n.toFixed(1)}%`;
}
function resolveUtil(vendorUtil: number, bps: number, bw: number): number {
if (vendorUtil > 0) return vendorUtil;
if (bw > 0 && bps > 0) return (bps / bw) * 100;
return vendorUtil || 0;
}
function formatHoverTime(sec: number): string {
return formatSystemTime(new Date(sec * 1000));
}
function yPadRange(min: number, max: number): [number, number] {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
if (min === max) {
const pad = Math.max(max * 0.25 || 0.1, max > 0 && max < 1 ? max * 0.5 : 10);
return [Math.max(0, min - pad * 0.1), max + pad];
}
return [Math.max(0, min * 0.95), max * 1.05];
}
function finiteMax(vals: number[]): number {
let m = Number.NEGATIVE_INFINITY;
for (const v of vals) {
if (Number.isFinite(v) && v > m) m = v;
}
return Number.isFinite(m) ? m : 0;
}
function pointValue(
p: PortTrafficSamplePoint,
dir: "in" | "out",
mode: WallYMode,
bwFallback: number,
): number {
const bps = dir === "in" ? Number(p.in_bps) || 0 : Number(p.out_bps) || 0;
if (mode !== "util") return bps;
const bw = Number(p.bw_bps) || bwFallback || 0;
const vendor = dir === "in" ? Number(p.in_util_pct) || 0 : Number(p.out_util_pct) || 0;
return resolveUtil(vendor, bps, bw);
}
function toAlignedSeries(
current: PortTrafficSamplePoint[],
baseline: PortTrafficSamplePoint[],
mode: WallYMode,
bwFallback: number,
baseBwFallback: number,
): [number[], number[], number[], number[], number[]] {
type Row = { x: number; inC?: number; outC?: number; inB?: number; outB?: number };
const map = new Map<number, Row>();
@ -33,8 +81,8 @@ function toAlignedSeries(
if (!d) continue;
const x = Math.floor(d.getTime() / 1000);
const row = map.get(x) || { x };
row.inC = Number(p.in_bps) || 0;
row.outC = Number(p.out_bps) || 0;
row.inC = pointValue(p, "in", mode, bwFallback);
row.outC = pointValue(p, "out", mode, bwFallback);
map.set(x, row);
}
for (const p of baseline) {
@ -42,8 +90,8 @@ function toAlignedSeries(
if (!d) continue;
const x = Math.floor(d.getTime() / 1000);
const row = map.get(x) || { x };
row.inB = Number(p.in_bps) || 0;
row.outB = Number(p.out_bps) || 0;
row.inB = pointValue(p, "in", mode, baseBwFallback || bwFallback);
row.outB = pointValue(p, "out", mode, baseBwFallback || bwFallback);
map.set(x, row);
}
const rows = [...map.values()].sort((a, b) => a.x - b.x);
@ -61,7 +109,12 @@ function toAlignedSeries(
function chartSize(el: HTMLElement): { width: number; height: number } {
const width = Math.max(320, el.clientWidth || el.parentElement?.clientWidth || 800);
const height = Math.max(260, Math.min(440, Math.floor(width * 0.34)));
const parentH = el.clientHeight || el.parentElement?.clientHeight || 0;
// Fill staged layout / fullscreen; fall back to aspect ratio in compact panels.
const height =
parentH >= 180
? Math.max(180, parentH)
: Math.max(260, Math.min(440, Math.floor(width * 0.34)));
return { width, height };
}
@ -73,6 +126,7 @@ type Props = {
points: PortTrafficSamplePoint[];
baselinePoints?: PortTrafficSamplePoint[];
rangeLabel: string;
yMode?: WallYMode;
loading?: boolean;
hint?: string;
};
@ -83,6 +137,7 @@ export function PortTrafficWall({
points,
baselinePoints = EMPTY_POINTS,
rangeLabel,
yMode = "auto",
loading,
hint,
}: Props) {
@ -92,21 +147,27 @@ export function PortTrafficWall({
const tipRef = useRef<HTMLDivElement | null>(null);
const pointsRef = useRef(points);
const baselineRef = useRef(baselinePoints);
const yModeRef = useRef(yMode);
const bwRef = useRef(0);
pointsRef.current = points;
baselineRef.current = baselinePoints;
yModeRef.current = yMode;
const latest = points.length ? points[points.length - 1] : null;
const kpi = useMemo(() => {
const bw = latest?.bw_bps || target?.bw_bps || 0;
const inBps = latest?.in_bps ?? 0;
const outBps = latest?.out_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,
inBps,
outBps,
inUtil: resolveUtil(latest?.in_util_pct ?? 0, inBps, bw),
outUtil: resolveUtil(latest?.out_util_pct ?? 0, outBps, bw),
ts: latest?.ts ?? null,
};
}, [latest, target]);
bwRef.current = kpi.bw;
useEffect(() => {
const el = mountRef.current;
@ -139,16 +200,16 @@ export function PortTrafficWall({
label: t("portTraffic.seriesCurrentIn"),
stroke: "#2dd4bf",
width: 2,
fill: "rgba(45, 212, 191, 0.12)",
points: { show: true, size: 5, fill: "#2dd4bf" },
fill: "rgba(45, 212, 191, 0.08)",
points: { show: false },
spanGaps: false,
},
{
label: t("portTraffic.seriesCurrentOut"),
stroke: "#f59e0b",
stroke: "#fbbf24",
width: 2,
fill: "rgba(245, 158, 11, 0.10)",
points: { show: true, size: 5, fill: "#f59e0b" },
fill: "rgba(251, 191, 36, 0.07)",
points: { show: false },
spanGaps: false,
},
{
@ -162,7 +223,7 @@ export function PortTrafficWall({
},
{
label: t("portTraffic.seriesBaselineOut"),
stroke: "rgba(245, 158, 11, 0.45)",
stroke: "rgba(251, 191, 36, 0.4)",
width: 1.5,
dash: [6, 4],
points: { show: false },
@ -173,16 +234,20 @@ export function PortTrafficWall({
axes: [
{
stroke: "#94a3b8",
grid: { stroke: "rgba(148,163,184,0.18)", width: 1 },
ticks: { stroke: "rgba(148,163,184,0.35)" },
grid: { show: false },
ticks: { stroke: "rgba(148,163,184,0.35)", size: 4 },
border: { show: false },
gap: 6,
},
{
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)),
border: { show: false },
values: (_u, splits) =>
splits.map((v) => (yModeRef.current === "util" ? `${v}` : formatBps(v))),
size: 64,
label: "bit/s",
label: yMode === "util" ? "%" : "bit/s",
labelFont: "12px sans-serif",
labelSize: 14,
},
@ -191,17 +256,19 @@ export function PortTrafficWall({
x: { time: true },
y: {
auto: true,
range: (_u, min, max) => {
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
if (min === max) {
const pad = Math.max(10, Math.abs(max) * 0.25 || 10);
return [Math.max(0, min - pad), max + pad];
range: (u, min, max) => {
if (yModeRef.current === "current") {
const vals = [...(u.data[1] || []), ...(u.data[2] || [])].filter((v) =>
Number.isFinite(v),
) as number[];
if (!vals.length) return yPadRange(min, max);
return yPadRange(0, finiteMax(vals));
}
return [Math.max(0, min * 0.95), max * 1.05];
return yPadRange(min, max);
},
},
},
legend: { show: true, live: false },
legend: { show: false },
cursor: {
drag: { x: true, y: false },
focus: { prox: 32 },
@ -221,10 +288,6 @@ export function PortTrafficWall({
}
const tsSec = Number(u.data[0][idx]);
const inBps = Number(u.data[1]?.[idx]);
const outBps = Number(u.data[2]?.[idx]);
const inBase = Number(u.data[3]?.[idx]);
const outBase = Number(u.data[4]?.[idx]);
const sample = pointsRef.current.find((p) => {
const d = parseApiTime(p.ts);
return d && Math.floor(d.getTime() / 1000) === tsSec;
@ -233,26 +296,39 @@ export function PortTrafficWall({
const d = parseApiTime(p.ts);
return d && Math.floor(d.getTime() / 1000) === tsSec;
});
const mode = yModeRef.current;
const yIn = Number(u.data[1]?.[idx]);
const yOut = Number(u.data[2]?.[idx]);
const yInB = Number(u.data[3]?.[idx]);
const yOutB = Number(u.data[4]?.[idx]);
const unit = mode === "util" ? "%" : "bit/s";
const fmtY = (v: number) =>
mode === "util" ? formatUtilPct(v).replace(/%$/, "") : formatBps(v);
if (!tip) return;
tip.style.display = "block";
const rows = [
`<div class="pt-wall__tip-time">${formatHoverTime(tsSec)}</div>`,
Number.isFinite(inBps)
? `<div class="pt-wall__tip-row pt-wall__tip-row--in"><span>In</span><strong>${formatBps(inBps)} bit/s</strong></div>`
Number.isFinite(yIn)
? `<div class="pt-wall__tip-row pt-wall__tip-row--in"><span>In</span><strong>${fmtY(yIn)} ${unit}</strong></div>`
: "",
Number.isFinite(outBps)
? `<div class="pt-wall__tip-row pt-wall__tip-row--out"><span>Out</span><strong>${formatBps(outBps)} bit/s</strong></div>`
: "",
sample
? `<div class="pt-wall__tip-row"><span>Util</span><strong>${Number(sample.in_util_pct || 0).toFixed(1)}% / ${Number(sample.out_util_pct || 0).toFixed(1)}%</strong></div>`
Number.isFinite(yOut)
? `<div class="pt-wall__tip-row pt-wall__tip-row--out"><span>Out</span><strong>${fmtY(yOut)} ${unit}</strong></div>`
: "",
];
if (Number.isFinite(inBase) || Number.isFinite(outBase)) {
if (sample) {
const bw = Number(sample.bw_bps) || bwRef.current || 0;
const inU = resolveUtil(Number(sample.in_util_pct) || 0, Number(sample.in_bps) || 0, bw);
const outU = resolveUtil(Number(sample.out_util_pct) || 0, Number(sample.out_bps) || 0, bw);
rows.push(
`<div class="pt-wall__tip-row"><span>Util</span><strong>${formatUtilPct(inU)} / ${formatUtilPct(outU)}</strong></div>`,
);
}
if (Number.isFinite(yInB) || Number.isFinite(yOutB)) {
rows.push(
`<div class="pt-wall__tip-row"><span>${t("portTraffic.baseline")}</span><strong>${
Number.isFinite(inBase) ? formatBps(inBase) : "—"
} / ${Number.isFinite(outBase) ? formatBps(outBase) : "—"} bit/s</strong></div>`,
Number.isFinite(yInB) ? fmtY(yInB) : "—"
} / ${Number.isFinite(yOutB) ? fmtY(yOutB) : "—"} ${unit}</strong></div>`,
);
if (baseSample?.ts_raw) {
rows.push(
@ -279,8 +355,16 @@ export function PortTrafficWall({
plotRef.current.setSize(chartSize(mountRef.current));
};
window.addEventListener("resize", onResize);
const ro =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => onResize())
: null;
ro?.observe(el);
const wrap = el.parentElement;
if (wrap) ro?.observe(wrap);
return () => {
window.removeEventListener("resize", onResize);
ro?.disconnect();
plotRef.current?.over.removeEventListener("mouseleave", onLeave);
plotRef.current?.destroy();
plotRef.current = null;
@ -291,9 +375,9 @@ export function PortTrafficWall({
useEffect(() => {
const plot = plotRef.current;
if (!plot) return;
// Always setData with resetScales; toggling series.show before setData can leave
// uPlot stuck on a blank [0,1] y-range until a later update.
const data = toAlignedSeries(points, baselinePoints);
const bw = kpi.bw || target?.bw_bps || 0;
const baseBw = baselineTarget?.bw_bps || bw;
const data = toAlignedSeries(points, baselinePoints, yMode, bw, baseBw);
const showBaseline = baselinePoints.length > 0;
plot.setData(data, true);
const s3 = Boolean(plot.series[3]?.show);
@ -302,15 +386,22 @@ export function PortTrafficWall({
plot.setSeries(3, { show: showBaseline }, false);
plot.setSeries(4, { show: showBaseline }, false);
}
const yAxis = plot.axes[1];
if (yAxis) {
const nextLabel = yMode === "util" ? "%" : "bit/s";
if (yAxis.label !== nextLabel) {
yAxis.label = nextLabel;
plot.redraw(false, false);
}
}
const el = mountRef.current;
if (el) {
// Legend show/hide changes layout; size after paint.
requestAnimationFrame(() => {
if (!plotRef.current || !mountRef.current) return;
plotRef.current.setSize(chartSize(mountRef.current));
});
}
}, [points, baselinePoints]);
}, [points, baselinePoints, yMode, kpi.bw, target?.bw_bps, baselineTarget?.bw_bps]);
const empty = points.length === 0 && baselinePoints.length === 0;
const neLabel = target ? target.ne_name || target.ne_ip || "—" : "—";
@ -326,7 +417,15 @@ export function PortTrafficWall({
return (
<div className="pt-wall">
<div className="pt-wall__head">
<div className="pt-wall__head-title">{t("portTraffic.wallLiveTitle")}</div>
<div className="pt-wall__title">
<div className="pt-wall__ne">
{neLabel}
{ipLabel ? ` · ${ipLabel}` : ""}
</div>
<div className="pt-wall__if" title={ifLabel}>
{ifLabel}
</div>
</div>
<div className="pt-wall__range">
{rangeLabel}
{kpi.ts ? ` · ${t("portTraffic.latestAt")} ${formatSystemTime(kpi.ts)}` : ""}
@ -334,37 +433,29 @@ export function PortTrafficWall({
</div>
<div className="pt-wall__kpis">
<div className="pt-wall__kpi">
<div className="pt-wall__kpi-label">
{t("portTraffic.kpiBw")} · {t("portTraffic.kpiLatest")}
</div>
<div className="pt-wall__kpi-label">{t("portTraffic.kpiBw")}</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 · {t("portTraffic.kpiLatest")}
</div>
<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 · {t("portTraffic.kpiLatest")}
</div>
<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 · {t("portTraffic.kpiLatest")}
</div>
<div className="pt-wall__kpi pt-wall__kpi--util">
<div className="pt-wall__kpi-label">Util</div>
<div className="pt-wall__kpi-value">
{kpi.inUtil.toFixed(1)}%
{formatUtilPct(kpi.inUtil)}
<span className="pt-wall__kpi-sep">/</span>
{kpi.outUtil.toFixed(1)}%
{formatUtilPct(kpi.outUtil)}
</div>
</div>
</div>
@ -378,17 +469,30 @@ export function PortTrafficWall({
) : null}
</div>
<div className="pt-wall__foot">
<div className="pt-wall__foot-ne" title={neLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.wallDevice")}</span>
<span className="pt-wall__foot-value">{neLabel}</span>
{ipLabel ? <span className="pt-wall__foot-ip">{ipLabel}</span> : null}
</div>
<div className="pt-wall__foot-if" title={ifLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.wallPort")}</span>
<span className="pt-wall__foot-value pt-wall__foot-value--mono">{ifLabel}</span>
<div className="pt-wall__legend" aria-hidden={!points.length && !baselinePoints.length}>
<span className="pt-wall__legend-item pt-wall__legend-item--in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesCurrentOut")}
</span>
{showBaselineMeta ? (
<>
<span className="pt-wall__legend-item pt-wall__legend-item--base-in">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineIn")}
</span>
<span className="pt-wall__legend-item pt-wall__legend-item--base-out">
<i className="pt-wall__legend-swatch" />
{t("portTraffic.seriesBaselineOut")}
</span>
</>
) : null}
</div>
{showBaselineMeta ? (
<>
<div className="pt-wall__foot-meta">
<div className="pt-wall__foot-ne" title={baseNeLabel}>
<span className="pt-wall__foot-label">{t("portTraffic.baselineDevice")}</span>
<span className="pt-wall__foot-value">{baseNeLabel}</span>
@ -398,7 +502,7 @@ export function PortTrafficWall({
<span className="pt-wall__foot-label">{t("portTraffic.baselinePort")}</span>
<span className="pt-wall__foot-value pt-wall__foot-value--mono">{baseIfLabel}</span>
</div>
</>
</div>
) : null}
</div>
</div>

View file

@ -0,0 +1,389 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import {
collectPortTrafficNow,
fetchPortTrafficCompare,
fetchPortTrafficDevices,
fetchPortTrafficTargets,
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import type { PortTrafficDevice, PortTrafficSamplePoint } from "../../types";
import { PortTrafficWall, type WallYMode } from "./PortTrafficWall";
const POLL_MS = 5000;
const EMPTY_COMPARE_POINTS: PortTrafficSamplePoint[] = [];
type BaselineMode = "off" | "shift" | "day" | "week" | "custom";
function deviceLabel(d: PortTrafficDevice) {
return d.ne_name || d.ne_ip || d.ne_id || "—";
}
function FullscreenIcon() {
return (
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
<path
fill="currentColor"
d="M7 14H5v5h5v-2H7v-3zm0-9h3V3H5v5h2V5zm12 9h-2v3h-3v2h5v-5zm-2-9V3h-3v2h3v3h2V5h-2z"
/>
</svg>
);
}
export function PortTrafficWallPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkAppliedRef = useRef(false);
const stageRef = useRef<HTMLDivElement | null>(null);
const [fullscreen, setFullscreen] = useState(false);
const [wallDeviceId, setWallDeviceId] = useState(() => (searchParams.get("device_id") || "").trim());
const [wallTargetId, setWallTargetId] = useState(() => (searchParams.get("target_id") || "").trim());
const [mapBaselineDeviceId, setMapBaselineDeviceId] = useState("");
const [mapBaselineTargetId, setMapBaselineTargetId] = useState("");
const [rangeHours, setRangeHours] = useState(24);
const [baseline, setBaseline] = useState<BaselineMode>("off");
const [customOffsetHours, setCustomOffsetHours] = useState(48);
const [yMode, setYMode] = useState<WallYMode>("auto");
const devicesQuery = useQuery({
queryKey: queryKeys.portTrafficDevices(0),
queryFn: () => fetchPortTrafficDevices({ page: 1, pageSize: 100 }),
staleTime: 5000,
refetchInterval: POLL_MS,
});
const wallTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(wallDeviceId),
queryFn: () => fetchPortTrafficTargets(wallDeviceId),
enabled: Boolean(wallDeviceId),
staleTime: 2000,
refetchInterval: POLL_MS,
});
const mapBaselineTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(mapBaselineDeviceId),
queryFn: () => fetchPortTrafficTargets(mapBaselineDeviceId),
enabled: Boolean(mapBaselineDeviceId),
staleTime: 2000,
refetchInterval: POLL_MS,
});
const compareQuery = useQuery({
queryKey: queryKeys.portTrafficCompare(
wallTargetId,
rangeHours,
baseline,
baseline === "custom" ? customOffsetHours : 0,
mapBaselineTargetId,
),
queryFn: () =>
fetchPortTrafficCompare({
targetId: wallTargetId,
rangeHours,
baseline,
offsetHours: baseline === "custom" ? customOffsetHours : undefined,
baselineTargetId: mapBaselineTargetId || undefined,
}),
enabled: Boolean(wallTargetId),
staleTime: 1000,
placeholderData: keepPreviousData,
refetchInterval: (q) => {
const n = q.state.data?.current?.length ?? 0;
return n === 0 ? 2500 : POLL_MS;
},
});
const devices = devicesQuery.data?.items || [];
const wallTargets = useMemo(
() => (wallTargetsQuery.data?.items || []).filter((x) => x.status === "active"),
[wallTargetsQuery.data?.items],
);
const selectedWallTarget = wallTargets.find((x) => x.id === wallTargetId) || null;
const mapBaselineOptions = useMemo(
() =>
(mapBaselineTargetsQuery.data?.items || []).filter(
(x) => x.status === "active" && x.id !== wallTargetId,
),
[mapBaselineTargetsQuery.data?.items, wallTargetId],
);
const wallPoints = compareQuery.data?.current ?? EMPTY_COMPARE_POINTS;
const wallBaselinePoints =
baseline === "off" && !mapBaselineTargetId
? EMPTY_COMPARE_POINTS
: (compareQuery.data?.baseline ?? EMPTY_COMPARE_POINTS);
useEffect(() => {
if (deepLinkAppliedRef.current) return;
const did = (searchParams.get("device_id") || "").trim();
const tid = (searchParams.get("target_id") || "").trim();
deepLinkAppliedRef.current = true;
if (did) setWallDeviceId(did);
if (tid) setWallTargetId(tid);
if (did || tid) setSearchParams({}, { replace: true });
}, [searchParams, setSearchParams]);
useEffect(() => {
if (wallDeviceId || !devices.length) return;
setWallDeviceId(devices[0].id);
}, [wallDeviceId, devices]);
useEffect(() => {
if (!wallDeviceId || wallTargetId || !wallTargets.length) return;
setWallTargetId(wallTargets[0].id);
}, [wallDeviceId, wallTargetId, wallTargets]);
useEffect(() => {
if (!mapBaselineTargetId) return;
if (mapBaselineTargetId === wallTargetId) {
setMapBaselineTargetId("");
return;
}
if (
mapBaselineDeviceId &&
mapBaselineTargetsQuery.isFetched &&
!mapBaselineOptions.some((x) => x.id === mapBaselineTargetId)
) {
setMapBaselineTargetId("");
}
}, [
mapBaselineTargetId,
wallTargetId,
mapBaselineDeviceId,
mapBaselineOptions,
mapBaselineTargetsQuery.isFetched,
]);
useEffect(() => {
const syncFs = () => {
const el = stageRef.current;
setFullscreen(Boolean(el && document.fullscreenElement === el));
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 80);
};
document.addEventListener("fullscreenchange", syncFs);
return () => document.removeEventListener("fullscreenchange", syncFs);
}, []);
const toggleFullscreen = async () => {
const el = stageRef.current;
if (!el) return;
try {
if (document.fullscreenElement === el) {
await document.exitFullscreen();
} else {
await el.requestFullscreen();
}
} catch (err) {
showError(String(err));
}
};
const collectMut = useMutation({
mutationFn: collectPortTrafficNow,
onSuccess: (res) => {
showOk(res.started ? t("portTraffic.collectStarted") : t("portTraffic.collectBusy"));
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficDevicesAll });
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTargets(wallDeviceId) });
void queryClient.invalidateQueries({ queryKey: ["portTrafficCompare"] });
},
onError: (e: Error) => showError(e.message),
});
return (
<section className="pt-wall-shell">
{!devices.length && !devicesQuery.isLoading ? (
<div className="pt-list-empty pt-wall-shell__empty">
<p>{t("portTraffic.wallEmpty")}</p>
<Link to="/network/tasks/port-traffic" className="btn-primary" style={{ textDecoration: "none" }}>
{t("portTraffic.create")}
</Link>
</div>
) : (
<div className="pt-wall-page">
<div className="pt-wall-page__toolbar">
<div className="pt-wall-page__row pt-wall-page__row--primary">
<label className="pt-wall-page__field pt-wall-page__field--task">
{t("portTraffic.wallDevice")}
<select
value={wallDeviceId}
onChange={(e) => {
setWallDeviceId(e.target.value);
setWallTargetId("");
}}
>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{deviceLabel(d)}
{d.ne_ip ? ` (${d.ne_ip})` : ""}
</option>
))}
</select>
</label>
<label className="pt-wall-page__field pt-wall-page__field--port">
{t("portTraffic.wallPort")}
<select value={wallTargetId} onChange={(e) => setWallTargetId(e.target.value)}>
<option value="">{t("portTraffic.pickPort")}</option>
{wallTargets.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</select>
</label>
<button
type="button"
className="btn-primary pt-wall-page__collect"
disabled={!wallDeviceId || collectMut.isPending}
onClick={() => collectMut.mutate(wallDeviceId)}
>
{t("portTraffic.collectNow")}
</button>
<button
type="button"
className="pt-wall-page__fs-btn pt-wall-page__fs-btn--toolbar"
onClick={() => void toggleFullscreen()}
title={t("portTraffic.fullscreen")}
aria-label={t("portTraffic.fullscreen")}
>
<FullscreenIcon />
<span>{t("portTraffic.fullscreen")}</span>
</button>
</div>
<div className="pt-wall-page__row pt-wall-page__row--secondary">
<div className="pt-wall-page__cluster">
<span className="pt-wall-page__cluster-label">{t("portTraffic.filterView")}</span>
<div className="pt-wall-page__cluster-fields">
<label className="pt-wall-page__field pt-wall-page__field--sm">
{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>
<label className="pt-wall-page__field pt-wall-page__field--md">
{t("portTraffic.compare")}
<select value={baseline} onChange={(e) => setBaseline(e.target.value as BaselineMode)}>
<option value="off">{t("portTraffic.compareOff")}</option>
<option value="day">{t("portTraffic.compareDay")}</option>
<option value="week">{t("portTraffic.compareWeek")}</option>
<option value="shift">{t("portTraffic.compareShift")}</option>
<option value="custom">{t("portTraffic.compareCustom")}</option>
</select>
</label>
{baseline === "custom" ? (
<label className="pt-wall-page__field pt-wall-page__field--sm">
{t("portTraffic.offsetHours")}
<input
type="number"
min={1}
max={24 * 90}
value={customOffsetHours}
onChange={(e) => setCustomOffsetHours(Number(e.target.value) || 24)}
/>
</label>
) : null}
<label className="pt-wall-page__field pt-wall-page__field--md">
{t("portTraffic.yMode")}
<select value={yMode} onChange={(e) => setYMode(e.target.value as WallYMode)}>
<option value="auto">{t("portTraffic.yModeAuto")}</option>
<option value="current">{t("portTraffic.yModeCurrent")}</option>
<option value="util">{t("portTraffic.yModeUtil")}</option>
</select>
</label>
</div>
</div>
<div className="pt-wall-page__cluster-sep" aria-hidden />
<div className="pt-wall-page__cluster pt-wall-page__cluster--map">
<span className="pt-wall-page__cluster-label">{t("portTraffic.filterMap")}</span>
<div className="pt-wall-page__cluster-fields">
<label className="pt-wall-page__field pt-wall-page__field--task">
{t("portTraffic.wallDevice")}
<select
value={mapBaselineDeviceId}
onChange={(e) => {
setMapBaselineDeviceId(e.target.value);
setMapBaselineTargetId("");
}}
disabled={!wallTargetId}
>
<option value="">{t("portTraffic.mapBaselineNone")}</option>
{devices.map((d) => (
<option key={d.id} value={d.id}>
{deviceLabel(d)}
</option>
))}
</select>
</label>
<label className="pt-wall-page__field pt-wall-page__field--port">
{t("portTraffic.wallPort")}
<select
value={mapBaselineTargetId}
onChange={(e) => setMapBaselineTargetId(e.target.value)}
disabled={!mapBaselineDeviceId}
>
<option value="">{t("portTraffic.pickPort")}</option>
{mapBaselineOptions.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</select>
</label>
</div>
</div>
</div>
</div>
{baseline === "week" ? (
<p className="muted pt-wall-page__hint">{t("portTraffic.retentionHint")}</p>
) : null}
<div
ref={stageRef}
className={`pt-wall-page__stage${fullscreen ? " is-fullscreen" : ""}`}
>
<PortTrafficWall
target={selectedWallTarget}
baselineTarget={
compareQuery.data?.meta?.baseline_target ||
(baseline !== "off" && wallBaselinePoints.length ? selectedWallTarget : null)
}
points={wallPoints}
baselinePoints={wallBaselinePoints}
yMode={yMode}
rangeLabel={`${rangeHours}h${
baseline === "off"
? ""
: baseline === "day"
? ` · ${t("portTraffic.compareDay")}`
: baseline === "week"
? ` · ${t("portTraffic.compareWeek")}`
: baseline === "shift"
? ` · ${t("portTraffic.compareShift")}`
: ` · ${t("portTraffic.compareCustom")}`
}${mapBaselineTargetId ? ` · ${t("portTraffic.mapBaselinePort")}` : ""}`}
loading={compareQuery.isLoading || compareQuery.isFetching}
hint={
!wallTargetId
? t("portTraffic.pickPort")
: selectedWallTarget?.last_error
? t("portTraffic.waitAfterError")
: t("portTraffic.waitSamples")
}
/>
</div>
</div>
)}
</section>
);
}

View file

@ -31,13 +31,15 @@ import type {
NeConfigSnapshotMeta,
PortTrafficCompare,
PortTrafficDashboard,
PortTrafficDevice,
PortTrafficDiscoverResponse,
PortTrafficEvent,
PortTrafficIfaceIn,
PortTrafficSamples,
PortTrafficTarget,
PortTrafficTargetIn,
PortTrafficTask,
} from "../types";
export const AUTH_TOKEN_KEY = "netx_access_token";
export const getAuthToken = (): string | null => {
@ -797,45 +799,77 @@ export const downloadNeConfigSnapshot = async (
export const fetchPortTrafficDashboard = () =>
apiGet<PortTrafficDashboard>("/v1/port-traffic/dashboard");
export const fetchPortTrafficTasks = (params: { page?: number; pageSize?: number }) => {
export const fetchPortTrafficDevices = (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()}`,
return apiGet<{ total: number; page: number; page_size: number; items: PortTrafficDevice[] }>(
`/v1/port-traffic/devices?${p.toString()}`,
);
};
export const createPortTrafficTask = (body: {
title: string;
/** @deprecated */
export const fetchPortTrafficTasks = fetchPortTrafficDevices;
export const createPortTrafficDevice = (body: {
source: "managed" | "ume";
ne_id: string;
ne_name?: string;
ne_ip?: string;
vendor?: string;
note?: string;
interval_sec?: number;
retention_days?: number;
concurrency?: number;
targets?: PortTrafficTargetIn[];
interfaces?: PortTrafficIfaceIn[];
start_now?: boolean;
}) => apiPost<PortTrafficTask>("/v1/port-traffic/tasks", body);
}) => apiPost<PortTrafficDevice>("/v1/port-traffic/devices", body);
export const startPortTrafficTask = (taskId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/start`, {});
export const updatePortTrafficDevice = (
deviceId: string,
body: {
note?: string;
interval_sec?: number;
retention_days?: number;
concurrency?: number;
ne_name?: string;
ne_ip?: string;
vendor?: string;
},
) => apiPatch<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`, body);
export const pausePortTrafficTask = (taskId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/pause`, {});
export const startPortTrafficDevice = (deviceId: string) =>
apiPost<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/start`, {});
export const stopPortTrafficTask = (taskId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/stop`, {});
export const pausePortTrafficDevice = (deviceId: string) =>
apiPost<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/pause`, {});
export const collectPortTrafficNow = (taskId: string) =>
apiPost<PortTrafficTask & { ok: boolean; started: boolean; reason?: string }>(
`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/collect-now`,
export const stopPortTrafficDevice = (deviceId: string) =>
apiPost<PortTrafficDevice>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/stop`, {});
export const collectPortTrafficNow = (deviceId: string) =>
apiPost<PortTrafficDevice & { ok: boolean; started: boolean; reason?: string }>(
`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/collect-now`,
{},
);
export const deletePortTrafficTask = (taskId: string) =>
apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}`);
export const deletePortTrafficDevice = (deviceId: string) =>
apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}`);
export const fetchPortTrafficTargets = (taskId: string) =>
export const fetchPortTrafficTargets = (deviceId: string) =>
apiGet<{ items: PortTrafficTarget[] }>(
`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/targets`,
`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/targets`,
);
export const fetchPortTrafficEvents = (deviceId: string, limit = 100) =>
apiGet<{ items: PortTrafficEvent[]; total: number }>(
`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/events?limit=${limit}`,
);
export const putPortTrafficInterfaces = (deviceId: string, interfaces: PortTrafficIfaceIn[]) =>
apiPut<{ items: PortTrafficTarget[] }>(
`/v1/port-traffic/devices/${encodeURIComponent(deviceId)}/interfaces`,
{ interfaces },
);
export const discoverPortTrafficPorts = (body: { source: "managed" | "ume"; id: string }) =>

View file

@ -533,9 +533,14 @@ export type NeConfigSnapshotDetail = NeConfigSnapshotMeta & {
config_alt_text: string;
};
export type PortTrafficTask = {
export type PortTrafficDevice = {
id: string;
title: string;
source: string;
ne_id: string;
ne_name: string;
ne_ip: string;
vendor: string;
note: string;
status: string;
interval_sec: number;
retention_days: number;
@ -550,9 +555,13 @@ export type PortTrafficTask = {
updated_at?: string | null;
};
/** @deprecated use PortTrafficDevice */
export type PortTrafficTask = PortTrafficDevice;
export type PortTrafficTarget = {
id: string;
task_id: string;
device_id: string;
task_id?: string;
series_id?: string;
source: string;
target_id: string;
@ -568,9 +577,20 @@ export type PortTrafficTarget = {
created_at?: string | null;
};
export type PortTrafficEvent = {
id: string;
device_id: string;
target_row_id: string;
ifname: string;
level: string;
message: string;
created_at?: string | null;
};
export type PortTrafficSeries = {
id: string;
task_id: string;
device_id: string;
task_id?: string;
title: string;
status: string;
active_target: PortTrafficTarget | null;
@ -578,17 +598,20 @@ export type PortTrafficSeries = {
created_at?: string | null;
};
export type PortTrafficTargetIn = {
source: "managed" | "ume";
target_id: string;
ne_name?: string;
ne_ip?: string;
vendor?: string;
export type PortTrafficIfaceIn = {
ifname: string;
if_description?: string;
bw_bps?: number;
};
export type PortTrafficTargetIn = PortTrafficIfaceIn & {
source?: "managed" | "ume";
target_id?: string;
ne_name?: string;
ne_ip?: string;
vendor?: string;
};
export type PortTrafficDiscoverPort = {
ifname: string;
attribute: string;
@ -642,10 +665,12 @@ export type PortTrafficCompare = {
};
export type PortTrafficDashboard = {
task_count: number;
running_task_count: number;
device_count: number;
running_device_count: number;
active_target_count: number;
sample_count_24h: number;
last_sample_at?: string | null;
task_count?: number;
running_task_count?: number;
};