mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
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:
parent
efe8f58cd2
commit
eea4043848
9 changed files with 240 additions and 54 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -103,6 +103,12 @@ const zh = {
|
|||
wallPort: "接口",
|
||||
pickPort: "选择接口",
|
||||
range: "时间范围",
|
||||
collectNow: "立即采集",
|
||||
collectStarted: "已触发采集",
|
||||
collectBusy: "采集进行中",
|
||||
targetError: "接口最近错误",
|
||||
waitSamples: "任务运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。",
|
||||
waitAfterError: "采集失败,请检查连通性后点「立即采集」重试。",
|
||||
kpi: {
|
||||
tasks: "任务数",
|
||||
running: "运行中",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)}`);
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue