Fix port traffic wall chart refresh and first-sample visibility.

Trigger collect on start, normalize sample time bounds, and keep the uPlot series updating live.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-31 15:10:33 +08:00
parent efe8f58cd2
commit eea4043848
9 changed files with 240 additions and 54 deletions

View file

@ -103,6 +103,12 @@ const en = {
wallPort: "Interface",
pickPort: "Select interface",
range: "Range",
collectNow: "Collect now",
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.",
kpi: {
tasks: "Tasks",
running: "Running",

View file

@ -103,6 +103,12 @@ const zh = {
wallPort: "接口",
pickPort: "选择接口",
range: "时间范围",
collectNow: "立即采集",
collectStarted: "已触发采集",
collectBusy: "采集进行中",
targetError: "接口最近错误",
waitSamples: "任务运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。",
waitAfterError: "采集失败,请检查连通性后点「立即采集」重试。",
kpi: {
tasks: "任务数",
running: "运行中",

View file

@ -3083,7 +3083,13 @@ pre {
}
.pt-wall__chart {
min-height: 240px;
min-height: 260px;
width: 100%;
}
.pt-wall__chart-wrap {
position: relative;
min-height: 260px;
width: 100%;
}
@ -3097,13 +3103,19 @@ pre {
}
.pt-wall__empty {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
min-height: 240px;
padding: 16px 24px;
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;
pointer-events: none;
}
@media (max-width: 900px) {

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
collectPortTrafficNow,
createPortTrafficTask,
deletePortTrafficTask,
discoverPortTrafficPorts,
@ -100,7 +101,8 @@ export function PortTrafficPage() {
queryKey: queryKeys.portTrafficTargets(wallTaskId),
queryFn: () => fetchPortTrafficTargets(wallTaskId),
enabled: view === "wall" && Boolean(wallTaskId),
staleTime: 3000,
staleTime: 2000,
refetchInterval: POLL_MS,
});
const samplesQuery = useQuery({
@ -115,8 +117,12 @@ export function PortTrafficPage() {
});
},
enabled: view === "wall" && Boolean(wallTargetId),
staleTime: 2000,
refetchInterval: POLL_MS,
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.
return n === 0 ? 2500 : POLL_MS;
},
});
const invalidateAll = () => {
@ -159,6 +165,16 @@ export function PortTrafficPage() {
},
onError: (e: Error) => showError(e.message),
});
const collectMut = useMutation({
mutationFn: collectPortTrafficNow,
onSuccess: (res) => {
showOk(res.started ? t("portTraffic.collectStarted") : t("portTraffic.collectBusy"));
invalidateAll();
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTargets(wallTaskId) });
void queryClient.invalidateQueries({ queryKey: ["portTrafficSamples"] });
},
onError: (e: Error) => showError(e.message),
});
const deleteMut = useMutation({
mutationFn: deletePortTrafficTask,
onSuccess: () => {
@ -632,11 +648,32 @@ export function PortTrafficPage() {
<option value={24}>24h</option>
</select>
</label>
<button
type="button"
className="btn-primary"
disabled={!wallTaskId || collectMut.isPending}
onClick={() => collectMut.mutate(wallTaskId)}
>
{t("portTraffic.collectNow")}
</button>
</div>
{selectedWallTarget?.last_error ? (
<p className="muted" style={{ marginBottom: 8 }}>
{t("portTraffic.targetError")}: {selectedWallTarget.last_error}
</p>
) : null}
<PortTrafficWall
target={selectedWallTarget}
points={samplesQuery.data?.points || []}
rangeLabel={`${rangeHours}h`}
loading={samplesQuery.isLoading || samplesQuery.isFetching}
hint={
!wallTargetId
? t("portTraffic.pickPort")
: selectedWallTarget?.last_error
? t("portTraffic.waitAfterError")
: t("portTraffic.waitSamples")
}
/>
</div>
) : null}

View file

@ -2,13 +2,14 @@ import { useEffect, useMemo, useRef } from "react";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types";
import { formatSystemTime, parseApiTime } from "../../utils/time";
function formatBps(n: number): string {
const v = Math.abs(n);
if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`;
if (v >= 1e6) return `${(n / 1e6).toFixed(2)} M`;
if (v >= 1e3) return `${(n / 1e3).toFixed(1)} K`;
return `${n.toFixed(0)}`;
return `${Math.round(n)}`;
}
function formatBwLabel(bps: number): string {
@ -16,14 +17,42 @@ function formatBwLabel(bps: number): string {
return `${formatBps(bps)}bit/s`;
}
function toSeries(points: PortTrafficSamplePoint[]): [number[], number[], number[]] {
const xs: number[] = [];
const ins: number[] = [];
const outs: number[] = [];
for (const p of points) {
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);
}
// Single sample: stretch a short segment so the line is visible.
if (xs.length === 1) {
xs.push(xs[0] + 60);
ins.push(ins[0]);
outs.push(outs[0]);
}
return [xs, ins, outs];
}
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)));
return { width, height };
}
type Props = {
target: PortTrafficTarget | null;
points: PortTrafficSamplePoint[];
rangeLabel: string;
loading?: boolean;
hint?: string;
};
export function PortTrafficWall({ target, points, rangeLabel }: Props) {
const rootRef = useRef<HTMLDivElement | null>(null);
export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: Props) {
const mountRef = useRef<HTMLDivElement | null>(null);
const plotRef = useRef<uPlot | null>(null);
const latest = points.length ? points[points.length - 1] : null;
@ -38,30 +67,30 @@ export function PortTrafficWall({ target, points, rangeLabel }: Props) {
};
}, [latest, target]);
// Create plot once; keep resize listener for lifetime of mount.
useEffect(() => {
const el = rootRef.current;
const el = mountRef.current;
if (!el) return;
const xs = points.map((p) => Math.floor(new Date(p.ts).getTime() / 1000));
const inSeries = points.map((p) => p.in_bps);
const outSeries = points.map((p) => p.out_bps);
const { width, height } = chartSize(el);
const opts: uPlot.Options = {
width: Math.max(320, el.clientWidth || 800),
height: Math.max(220, Math.min(420, Math.floor((el.clientWidth || 800) * 0.32))),
width,
height,
series: [
{},
{
label: "In",
label: "In bit/s",
stroke: "#2dd4bf",
width: 2,
points: { show: false },
fill: "rgba(45, 212, 191, 0.12)",
points: { show: true, size: 5, fill: "#2dd4bf" },
},
{
label: "Out",
label: "Out bit/s",
stroke: "#f59e0b",
width: 2,
points: { show: false },
fill: "rgba(245, 158, 11, 0.10)",
points: { show: true, size: 5, fill: "#f59e0b" },
},
],
axes: [
@ -75,34 +104,34 @@ export function PortTrafficWall({ target, points, rangeLabel }: Props) {
grid: { stroke: "rgba(148,163,184,0.12)", width: 1 },
ticks: { stroke: "rgba(148,163,184,0.35)" },
values: (_u, splits) => splits.map((v) => formatBps(v)),
size: 56,
size: 64,
label: "bit/s",
labelFont: "12px sans-serif",
labelSize: 14,
},
],
scales: {
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];
}
return [Math.max(0, min * 0.95), max * 1.05];
},
},
},
legend: { show: true },
cursor: {
drag: { x: true, y: false },
},
cursor: { drag: { x: true, y: false } },
};
plotRef.current?.destroy();
plotRef.current = null;
if (xs.length === 0) {
el.replaceChildren();
return;
}
plotRef.current = new uPlot(opts, [xs, inSeries, outSeries], el);
plotRef.current = new uPlot(opts, [[], [], []], el);
const onResize = () => {
if (!plotRef.current || !rootRef.current) return;
plotRef.current.setSize({
width: Math.max(320, rootRef.current.clientWidth),
height: Math.max(220, Math.min(420, Math.floor(rootRef.current.clientWidth * 0.32))),
});
if (!plotRef.current || !mountRef.current) return;
plotRef.current.setSize(chartSize(mountRef.current));
};
window.addEventListener("resize", onResize);
return () => {
@ -110,8 +139,19 @@ export function PortTrafficWall({ target, points, rangeLabel }: Props) {
plotRef.current?.destroy();
plotRef.current = null;
};
}, []);
// Push live samples into the existing chart.
useEffect(() => {
const plot = plotRef.current;
if (!plot) return;
const data = toSeries(points);
plot.setData(data);
if (mountRef.current) plot.setSize(chartSize(mountRef.current));
}, [points]);
const empty = points.length === 0;
return (
<div className="pt-wall">
<div className="pt-wall__head">
@ -119,7 +159,10 @@ export function PortTrafficWall({ target, points, rangeLabel }: Props) {
<span className="pt-wall__ne">{target ? target.ne_name || target.ne_ip || "—" : "—"}</span>
<span className="pt-wall__if">{target?.ifname || "Select interface"}</span>
</div>
<div className="pt-wall__range">{rangeLabel}</div>
<div className="pt-wall__range">
{rangeLabel}
{latest?.ts ? ` · last ${formatSystemTime(latest.ts)}` : ""}
</div>
</div>
<div className="pt-wall__kpis">
<div className="pt-wall__kpi">
@ -149,8 +192,13 @@ export function PortTrafficWall({ target, points, rangeLabel }: Props) {
</div>
</div>
</div>
<div className="pt-wall__chart" ref={rootRef}>
{!points.length ? <div className="pt-wall__empty">No samples in range</div> : null}
<div className="pt-wall__chart-wrap">
<div className="pt-wall__chart" ref={mountRef} />
{empty ? (
<div className="pt-wall__empty">
{loading ? "Loading samples…" : hint || "Waiting for samples…"}
</div>
) : null}
</div>
</div>
);

View file

@ -816,6 +816,12 @@ export const pausePortTrafficTask = (taskId: string) =>
export const stopPortTrafficTask = (taskId: string) =>
apiPost<PortTrafficTask>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/stop`, {});
export const collectPortTrafficNow = (taskId: string) =>
apiPost<PortTrafficTask & { ok: boolean; started: boolean; reason?: string }>(
`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}/collect-now`,
{},
);
export const deletePortTrafficTask = (taskId: string) =>
apiDelete<{ ok: boolean; id: string }>(`/v1/port-traffic/tasks/${encodeURIComponent(taskId)}`);

View file

@ -21,14 +21,24 @@ function normalizeTimeInput(value: string | number | Date, options?: FormatTimeO
return `${text.replace(" ", "T")}Z`;
}
/** Parse API timestamps; naive ISO strings are treated as UTC. */
export function parseApiTime(
value: string | number | Date | null | undefined,
options?: FormatTimeOptions,
): Date | null {
if (value === null || value === undefined) return null;
const normalized = normalizeTimeInput(value, options);
const d = normalized instanceof Date ? normalized : new Date(normalized);
return Number.isNaN(d.getTime()) ? null : d;
}
export function formatSystemTime(
value: string | number | Date | null | undefined,
options?: FormatTimeOptions,
): string {
if (value === null || value === undefined) return "";
const normalized = normalizeTimeInput(value, options);
const d = normalized instanceof Date ? normalized : new Date(normalized);
if (Number.isNaN(d.getTime())) return String(value);
const d = parseApiTime(value, options);
if (!d) return String(value);
return d.toLocaleString(undefined, {
hour12: false,
timeZone: systemTimeZone,