mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 02:00:46 +08:00
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:
parent
5079343b2a
commit
214bc56774
24 changed files with 3309 additions and 1357 deletions
22
web/WEB.md
22
web/WEB.md
|
|
@ -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=` 打开向导并预填网元
|
||||
|
||||
## 拓扑管理
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "周期",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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>
|
||||
|
|
|
|||
389
web/src/pages/network/PortTrafficWallPage.tsx
Normal file
389
web/src/pages/network/PortTrafficWallPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 }) =>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
};
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue