Add period and cross-task interface compare for port traffic wall.

Compare by interface target_id with optional mapped baseline (same window or time-shifted), keep chart samples on target_row_id, and unify UI wording to interface.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-31 22:53:26 +08:00
parent 0d85b87be7
commit 653baa1b9f
16 changed files with 1185 additions and 129 deletions

View file

@ -107,12 +107,15 @@ src/
## 端口流量监控
- API:`/v1/port-traffic/*`(任务 CRUD、discover/ports、samples、dashboard)
- 厂商:ZTE(`show interface brief` / `show interface {if}`)、华为(`display interface brief` / `display interface {if}`)、思科(`show ip interface brief` / `show interfaces {if}`);解析速率 **bit/s**(华为暂无带宽则 `bw_bps=0`;思科暂无利用率则 util=0)
- 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
- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 监控大屏)
- 保留:按任务 `retention_days` 清理过期 sample(周对比建议 ≥8 天)
- 前端:`/network/tasks/port-traffic`(四步向导 + 任务启停 + uPlot 大屏;接口选择 + 周期对比 + 跨任务映射基线接口)
## WebCRT

View file

@ -65,4 +65,11 @@ export const queryKeys = {
portTrafficTargets: (taskId: string) => ["portTrafficTargets", taskId] as const,
portTrafficSamples: (targetId: string, rangeHours: number) =>
["portTrafficSamples", targetId, rangeHours] as const,
portTrafficCompare: (
targetId: string,
rangeHours: number,
baseline: string,
offsetHours: number,
baselineTargetId: string,
) => ["portTrafficCompare", targetId, rangeHours, baseline, offsetHours, baselineTargetId] as const,
};

View file

@ -81,28 +81,47 @@ const en = {
delete: "Delete",
step1: "Params",
step2: "NEs",
step3: "Ports",
step3: "Interfaces",
step4: "Confirm",
prev: "Back",
next: "Next",
fieldTitle: "Task name",
titlePh: "e.g. Access uplinks",
titleRequired: "Task name is required",
portsRequired: "Select at least one port",
portsRequired: "Select at least one interface",
interval: "Interval (sec)",
retention: "Retention (days)",
retentionHintWizard: "Use ≥8 days for week-over-week compare",
concurrency: "Concurrency",
startNow: "Start after create",
neKeywordPh: "Name / IP / vendor",
selectedNe: "{{count}} NE(s) selected",
selectedPorts: "{{count}} port(s) selected",
selectedPorts: "{{count}} interface(s) selected",
pickNeFirst: "Select NEs in the previous step first",
discover: "Discover ports",
discover: "Discover interfaces",
confirmCreate: "Create task",
wallTask: "Task",
wallPort: "Interface",
wallDevice: "Device",
pickPort: "Select interface",
range: "Range",
compare: "Compare",
compareOff: "Off",
compareDay: "Yesterday (same TOD)",
compareWeek: "Last week (same weekday)",
compareShift: "Previous window",
compareCustom: "Custom offset",
offsetHours: "Offset (hours)",
baseline: "Baseline",
baselineAt: "Baseline time",
seriesCurrentIn: "Current In",
seriesCurrentOut: "Current Out",
seriesBaselineIn: "Baseline In",
seriesBaselineOut: "Baseline Out",
mapBaselinePort: "Mapped baseline interface",
mapBaselineTask: "Mapped baseline task",
mapBaselineNone: "None",
retentionHint: "Week compare needs retention_days ≥ 8 or the baseline may be empty.",
collectNow: "Collect now",
collectStarted: "Collect triggered",
collectBusy: "Collect already running",
@ -114,17 +133,16 @@ const en = {
latestAt: "Latest sample",
hoverPoint: "Cursor",
wallLiveTitle: "Live traffic",
wallDevice: "Device",
kpi: {
tasks: "Tasks",
running: "Running",
ports: "Ports",
ports: "Interfaces",
samples24h: "Samples 24h",
},
col: {
title: "Task",
status: "Status",
ports: "Ports",
ports: "Interfaces",
interval: "Interval",
last: "Last collect",
actions: "Actions",

View file

@ -81,28 +81,47 @@ const zh = {
delete: "删除",
step1: "参数",
step2: "选网元",
step3: "选端口",
step3: "选接口",
step4: "确认",
prev: "上一步",
next: "下一步",
fieldTitle: "任务名称",
titlePh: "例如:接入侧上联",
titleRequired: "请填写任务名称",
portsRequired: "请至少选择一个端口",
portsRequired: "请至少选择一个接口",
interval: "周期(秒)",
retention: "保留(天)",
retentionHintWizard: "若需上周同日对比,建议 ≥8 天",
concurrency: "并发",
startNow: "创建后立即启动",
neKeywordPh: "名称 / IP / 厂商",
selectedNe: "已选 {{count}} 台网元",
selectedPorts: "已选 {{count}} 个端口",
selectedPorts: "已选 {{count}} 个接口",
pickNeFirst: "请先在上一步选择网元",
discover: "拉取端口",
discover: "拉取接口",
confirmCreate: "创建任务",
wallTask: "任务",
wallPort: "接口",
wallDevice: "网元",
pickPort: "选择接口",
range: "时间范围",
compare: "周期对比",
compareOff: "关闭",
compareDay: "昨日同时刻",
compareWeek: "上周同日",
compareShift: "前一窗",
compareCustom: "自定义偏移",
offsetHours: "偏移(小时)",
baseline: "基线",
baselineAt: "基线原时刻",
seriesCurrentIn: "当前 In",
seriesCurrentOut: "当前 Out",
seriesBaselineIn: "基线 In",
seriesBaselineOut: "基线 Out",
mapBaselinePort: "映射基线接口",
mapBaselineTask: "映射基线任务",
mapBaselineNone: "不映射",
retentionHint: "周对比建议任务保留天数 ≥ 8;否则基线可能无数据。",
collectNow: "立即采集",
collectStarted: "已触发采集",
collectBusy: "采集进行中",
@ -114,17 +133,16 @@ const zh = {
latestAt: "最新样点",
hoverPoint: "光标位置",
wallLiveTitle: "实时流量",
wallDevice: "设备",
kpi: {
tasks: "任务数",
running: "运行中",
ports: "监控端口",
ports: "监控接口",
samples24h: "24h 样点",
},
col: {
title: "任务",
status: "状态",
ports: "端口",
ports: "接口",
interval: "周期",
last: "最近采集",
actions: "操作",

View file

@ -6,8 +6,8 @@ import {
deletePortTrafficTask,
discoverPortTrafficPorts,
fetchCliTargets,
fetchPortTrafficCompare,
fetchPortTrafficDashboard,
fetchPortTrafficSamples,
fetchPortTrafficTargets,
fetchPortTrafficTasks,
pausePortTrafficTask,
@ -26,6 +26,7 @@ const POLL_MS = 5000;
const TARGET_PAGE_SIZE = 20;
type PortPick = PortTrafficTargetIn & { key: string };
type BaselineMode = "off" | "shift" | "day" | "week" | "custom";
function neKey(source: string, id: string) {
return `${source}:${id}`;
@ -51,7 +52,11 @@ export function PortTrafficPage() {
const [taskPage, setTaskPage] = useState(1);
const [wallTaskId, setWallTaskId] = useState("");
const [wallTargetId, setWallTargetId] = useState("");
const [rangeHours, setRangeHours] = useState(1);
const [mapBaselineTaskId, setMapBaselineTaskId] = useState("");
const [mapBaselineTargetId, setMapBaselineTargetId] = useState("");
const [rangeHours, setRangeHours] = useState(24);
const [baseline, setBaseline] = useState<BaselineMode>("off");
const [customOffsetHours, setCustomOffsetHours] = useState(48);
// Wizard state
const [step, setStep] = useState(1);
@ -105,22 +110,34 @@ export function PortTrafficPage() {
refetchInterval: POLL_MS,
});
const samplesQuery = useQuery({
queryKey: queryKeys.portTrafficSamples(wallTargetId, rangeHours),
queryFn: () => {
const to = new Date();
const from = new Date(to.getTime() - rangeHours * 3600 * 1000);
return fetchPortTrafficSamples({
const mapBaselineTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(mapBaselineTaskId),
queryFn: () => fetchPortTrafficTargets(mapBaselineTaskId),
enabled: view === "wall" && Boolean(mapBaselineTaskId),
staleTime: 2000,
refetchInterval: POLL_MS,
});
const compareQuery = useQuery({
queryKey: queryKeys.portTrafficCompare(
wallTargetId,
rangeHours,
baseline,
baseline === "custom" ? customOffsetHours : 0,
mapBaselineTargetId,
),
queryFn: () =>
fetchPortTrafficCompare({
targetId: wallTargetId,
from: from.toISOString(),
to: to.toISOString(),
});
},
rangeHours,
baseline,
offsetHours: baseline === "custom" ? customOffsetHours : undefined,
baselineTargetId: mapBaselineTargetId || undefined,
}),
enabled: view === "wall" && Boolean(wallTargetId),
staleTime: 1000,
refetchInterval: (q) => {
const n = q.state.data?.points?.length ?? 0;
// Faster while waiting for the first sample; then every 5s for live wall.
const n = q.state.data?.current?.length ?? 0;
return n === 0 ? 2500 : POLL_MS;
},
});
@ -171,7 +188,7 @@ export function PortTrafficPage() {
showOk(res.started ? t("portTraffic.collectStarted") : t("portTraffic.collectBusy"));
invalidateAll();
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTargets(wallTaskId) });
void queryClient.invalidateQueries({ queryKey: ["portTrafficSamples"] });
void queryClient.invalidateQueries({ queryKey: ["portTrafficCompare"] });
},
onError: (e: Error) => showError(e.message),
});
@ -206,6 +223,9 @@ export function PortTrafficPage() {
const openWall = (taskId: string) => {
setWallTaskId(taskId);
setWallTargetId("");
setMapBaselineTaskId("");
setMapBaselineTargetId("");
setBaseline("off");
setView("wall");
};
@ -265,14 +285,46 @@ export function PortTrafficPage() {
const selectedNeList = useMemo(() => Object.values(selectedNes), [selectedNes]);
const pickedList = useMemo(() => Object.values(pickedPorts), [pickedPorts]);
const wallTargets = wallTargetsQuery.data?.items || [];
const wallTaskOptions = wallTasksQuery.data?.items || tasksQuery.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],
);
useEffect(() => {
if (view !== "wall" || wallTargetId || !wallTargets.length) return;
setWallTargetId(wallTargets[0].id);
}, [view, wallTargetId, wallTargets]);
useEffect(() => {
if (!mapBaselineTargetId) return;
if (mapBaselineTargetId === wallTargetId) {
setMapBaselineTargetId("");
return;
}
if (
mapBaselineTaskId &&
mapBaselineTargetsQuery.isFetched &&
!mapBaselineOptions.some((x) => x.id === mapBaselineTargetId)
) {
setMapBaselineTargetId("");
}
}, [
mapBaselineTargetId,
wallTargetId,
mapBaselineTaskId,
mapBaselineOptions,
mapBaselineTargetsQuery.isFetched,
]);
const submitWizard = () => {
if (!title.trim()) {
showError(t("portTraffic.titleRequired"));
@ -459,6 +511,9 @@ export function PortTrafficPage() {
value={retentionDays}
onChange={(e) => setRetentionDays(Number(e.target.value) || 7)}
/>
<span className="muted" style={{ fontSize: 12 }}>
{t("portTraffic.retentionHintWizard")}
</span>
</label>
<label className="config-sync-policy-field">
{t("portTraffic.concurrency")}
@ -622,7 +677,7 @@ export function PortTrafficPage() {
setWallTargetId("");
}}
>
{(wallTasksQuery.data?.items || tasksQuery.data?.items || []).map((task) => (
{wallTaskOptions.map((task) => (
<option key={task.id} value={task.id}>
{task.title}
</option>
@ -631,11 +686,15 @@ export function PortTrafficPage() {
</label>
<label>
{t("portTraffic.wallPort")}
<select value={wallTargetId} onChange={(e) => setWallTargetId(e.target.value)}>
<select
value={wallTargetId}
onChange={(e) => setWallTargetId(e.target.value)}
>
<option value="">{t("portTraffic.pickPort")}</option>
{wallTargets.map((tg) => (
<option key={tg.id} value={tg.id}>
{tg.ne_name} / {tg.ifname}
{wallTargets.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ne_name || tgt.ne_ip || "—"} / {tgt.ifname}
{tgt.ne_ip ? ` (${tgt.ne_ip})` : ""}
</option>
))}
</select>
@ -648,6 +707,66 @@ export function PortTrafficPage() {
<option value={24}>24h</option>
</select>
</label>
<label>
{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>
{t("portTraffic.offsetHours")}
<input
type="number"
min={1}
max={24 * 90}
value={customOffsetHours}
onChange={(e) => setCustomOffsetHours(Number(e.target.value) || 24)}
style={{ width: 88 }}
/>
</label>
) : null}
<label>
{t("portTraffic.mapBaselineTask")}
<select
value={mapBaselineTaskId}
onChange={(e) => {
setMapBaselineTaskId(e.target.value);
setMapBaselineTargetId("");
}}
disabled={!wallTargetId}
>
<option value="">{t("portTraffic.mapBaselineNone")}</option>
{wallTaskOptions.map((task) => (
<option key={task.id} value={task.id}>
{task.title}
</option>
))}
</select>
</label>
<label>
{t("portTraffic.mapBaselinePort")}
<select
value={mapBaselineTargetId}
onChange={(e) => setMapBaselineTargetId(e.target.value)}
disabled={!mapBaselineTaskId}
>
<option value="">{t("portTraffic.pickPort")}</option>
{mapBaselineOptions.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ne_name || tgt.ne_ip || "—"} / {tgt.ifname}
{tgt.ne_ip ? ` (${tgt.ne_ip})` : ""}
</option>
))}
</select>
</label>
<button
type="button"
className="btn-primary"
@ -657,6 +776,11 @@ export function PortTrafficPage() {
{t("portTraffic.collectNow")}
</button>
</div>
{baseline === "week" ? (
<p className="muted" style={{ marginBottom: 8 }}>
{t("portTraffic.retentionHint")}
</p>
) : null}
{selectedWallTarget?.last_error ? (
<p className="muted" style={{ marginBottom: 8 }}>
{t("portTraffic.targetError")}: {selectedWallTarget.last_error}
@ -664,9 +788,24 @@ export function PortTrafficPage() {
) : null}
<PortTrafficWall
target={selectedWallTarget}
points={samplesQuery.data?.points || []}
rangeLabel={`${rangeHours}h`}
loading={samplesQuery.isLoading || samplesQuery.isFetching}
points={compareQuery.data?.current || []}
baselinePoints={
baseline === "off" && !mapBaselineTargetId
? []
: compareQuery.data?.baseline || []
}
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")

View file

@ -22,23 +22,41 @@ function formatHoverTime(sec: number): string {
return formatSystemTime(new Date(sec * 1000));
}
function toSeries(points: PortTrafficSamplePoint[]): [number[], number[], number[]] {
const xs: number[] = [];
const ins: number[] = [];
const outs: number[] = [];
for (const p of points) {
function toAlignedSeries(
current: PortTrafficSamplePoint[],
baseline: PortTrafficSamplePoint[],
): [number[], number[], number[], number[], number[]] {
type Row = { x: number; inC?: number; outC?: number; inB?: number; outB?: number };
const map = new Map<number, Row>();
for (const p of current) {
const d = parseApiTime(p.ts);
if (!d) continue;
xs.push(Math.floor(d.getTime() / 1000));
ins.push(Number(p.in_bps) || 0);
outs.push(Number(p.out_bps) || 0);
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;
map.set(x, row);
}
if (xs.length === 1) {
xs.push(xs[0] + 60);
ins.push(ins[0]);
outs.push(outs[0]);
for (const p of baseline) {
const d = parseApiTime(p.ts);
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;
map.set(x, row);
}
return [xs, ins, outs];
const rows = [...map.values()].sort((a, b) => a.x - b.x);
if (rows.length === 1) {
rows.push({ ...rows[0], x: rows[0].x + 60 });
}
return [
rows.map((r) => r.x),
rows.map((r) => r.inC ?? NaN),
rows.map((r) => r.outC ?? NaN),
rows.map((r) => r.inB ?? NaN),
rows.map((r) => r.outB ?? NaN),
];
}
function chartSize(el: HTMLElement): { width: number; height: number } {
@ -50,18 +68,28 @@ function chartSize(el: HTMLElement): { width: number; height: number } {
type Props = {
target: PortTrafficTarget | null;
points: PortTrafficSamplePoint[];
baselinePoints?: PortTrafficSamplePoint[];
rangeLabel: string;
loading?: boolean;
hint?: string;
};
export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: Props) {
export function PortTrafficWall({
target,
points,
baselinePoints = [],
rangeLabel,
loading,
hint,
}: Props) {
const { t } = useI18n();
const mountRef = useRef<HTMLDivElement | null>(null);
const plotRef = useRef<uPlot | null>(null);
const tipRef = useRef<HTMLDivElement | null>(null);
const pointsRef = useRef(points);
const baselineRef = useRef(baselinePoints);
pointsRef.current = points;
baselineRef.current = baselinePoints;
const latest = points.length ? points[points.length - 1] : null;
const kpi = useMemo(() => {
@ -104,18 +132,38 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
series: [
{},
{
label: "In",
label: t("portTraffic.seriesCurrentIn"),
stroke: "#2dd4bf",
width: 2,
fill: "rgba(45, 212, 191, 0.12)",
points: { show: true, size: 5, fill: "#2dd4bf" },
spanGaps: false,
},
{
label: "Out",
label: t("portTraffic.seriesCurrentOut"),
stroke: "#f59e0b",
width: 2,
fill: "rgba(245, 158, 11, 0.10)",
points: { show: true, size: 5, fill: "#f59e0b" },
spanGaps: false,
},
{
label: t("portTraffic.seriesBaselineIn"),
stroke: "rgba(45, 212, 191, 0.45)",
width: 1.5,
dash: [6, 4],
points: { show: false },
spanGaps: false,
show: false,
},
{
label: t("portTraffic.seriesBaselineOut"),
stroke: "rgba(245, 158, 11, 0.45)",
width: 1.5,
dash: [6, 4],
points: { show: false },
spanGaps: false,
show: false,
},
],
axes: [
@ -149,7 +197,6 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
},
},
},
// Color keys only — no live numeric values (those are in the hover tip).
legend: { show: true, live: false },
cursor: {
drag: { x: true, y: false },
@ -170,30 +217,53 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
}
const tsSec = Number(u.data[0][idx]);
const inBps = Number(u.data[1]?.[idx] ?? 0);
const outBps = Number(u.data[2]?.[idx] ?? 0);
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;
});
const baseSample = baselineRef.current.find((p) => {
const d = parseApiTime(p.ts);
return d && Math.floor(d.getTime() / 1000) === tsSec;
});
if (!tip) return;
tip.style.display = "block";
tip.innerHTML = [
const rows = [
`<div class="pt-wall__tip-time">${formatHoverTime(tsSec)}</div>`,
`<div class="pt-wall__tip-row pt-wall__tip-row--in"><span>In</span><strong>${formatBps(inBps)} bit/s</strong></div>`,
`<div class="pt-wall__tip-row pt-wall__tip-row--out"><span>Out</span><strong>${formatBps(outBps)} bit/s</strong></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(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>`
: "",
].join("");
];
if (Number.isFinite(inBase) || Number.isFinite(outBase)) {
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>`,
);
if (baseSample?.ts_raw) {
rows.push(
`<div class="pt-wall__tip-row"><span>${t("portTraffic.baselineAt")}</span><strong>${formatSystemTime(baseSample.ts_raw)}</strong></div>`,
);
}
}
tip.innerHTML = rows.join("");
placeTip(u, left, top);
},
],
},
};
plotRef.current = new uPlot(opts, [[], [], []], el);
plotRef.current = new uPlot(opts, [[], [], [], [], []], el);
const onLeave = () => {
if (tipRef.current) tipRef.current.style.display = "none";
@ -211,16 +281,20 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
plotRef.current?.destroy();
plotRef.current = null;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
useEffect(() => {
const plot = plotRef.current;
if (!plot) return;
plot.setData(toSeries(points));
const showBaseline = baselinePoints.length > 0;
plot.setSeries(3, { show: showBaseline });
plot.setSeries(4, { show: showBaseline });
plot.setData(toAlignedSeries(points, baselinePoints));
if (mountRef.current) plot.setSize(chartSize(mountRef.current));
}, [points]);
}, [points, baselinePoints]);
const empty = points.length === 0;
const empty = points.length === 0 && baselinePoints.length === 0;
const neLabel = target ? target.ne_name || target.ne_ip || "—" : "—";
const ifLabel = target?.ifname || "—";
const ipLabel = target?.ne_ip || "";

View file

@ -29,11 +29,11 @@ import type {
ConfigSyncTask,
NeConfigSnapshotDetail,
NeConfigSnapshotMeta,
PortTrafficCompare,
PortTrafficDashboard,
PortTrafficDiscoverResponse,
PortTrafficSamples,
PortTrafficTarget,
PortTrafficTargetIn,
PortTrafficTask,
} from "../types";
@ -847,3 +847,21 @@ export const fetchPortTrafficSamples = (params: {
if (params.to) p.set("to", params.to);
return apiGet<PortTrafficSamples>(`/v1/port-traffic/samples?${p.toString()}`);
};
export const fetchPortTrafficCompare = (params: {
targetId: string;
rangeHours: number;
baseline: string;
offsetHours?: number;
baselineTargetId?: string;
to?: string;
}) => {
const p = new URLSearchParams();
p.set("target_id", params.targetId);
p.set("range_hours", String(params.rangeHours));
p.set("baseline", params.baseline);
if (params.offsetHours != null) p.set("offset_hours", String(params.offsetHours));
if (params.baselineTargetId) p.set("baseline_target_id", params.baselineTargetId);
if (params.to) p.set("to", params.to);
return apiGet<PortTrafficCompare>(`/v1/port-traffic/compare?${p.toString()}`);
};

View file

@ -527,6 +527,7 @@ export type PortTrafficTask = {
export type PortTrafficTarget = {
id: string;
task_id: string;
series_id?: string;
source: string;
target_id: string;
ne_name: string;
@ -541,6 +542,16 @@ export type PortTrafficTarget = {
created_at?: string | null;
};
export type PortTrafficSeries = {
id: string;
task_id: string;
title: string;
status: string;
active_target: PortTrafficTarget | null;
retired_target_count: number;
created_at?: string | null;
};
export type PortTrafficTargetIn = {
source: "managed" | "ume";
target_id: string;
@ -582,6 +593,7 @@ export type PortTrafficSamplePoint = {
out_util_pct: number;
bw_bps: number;
rate_period_sec: number;
ts_raw?: string | null;
};
export type PortTrafficSamples = {
@ -589,6 +601,20 @@ export type PortTrafficSamples = {
points: PortTrafficSamplePoint[];
};
export type PortTrafficCompare = {
meta: {
target_id: string;
baseline: string;
offset_hours: number;
range_hours: number;
current_target: PortTrafficTarget | null;
baseline_target: PortTrafficTarget | null;
baseline_target_id: string;
};
current: PortTrafficSamplePoint[];
baseline: PortTrafficSamplePoint[];
};
export type PortTrafficDashboard = {
task_count: number;
running_task_count: number;