mirror of
https://github.com/hansjone/netx.git
synced 2026-10-18 19:55:56 +08:00
Show latest KPIs and hover tooltips on the port traffic wall chart.
Cursor tracking syncs In/Out/Util values and displays a live bit/s tooltip. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
eea4043848
commit
c105cd8fd6
4 changed files with 199 additions and 20 deletions
|
|
@ -109,6 +109,10 @@ const en = {
|
||||||
targetError: "Last interface error",
|
targetError: "Last interface error",
|
||||||
waitSamples: "Samples appear after the task runs; or click Collect now. Chart auto-refreshes.",
|
waitSamples: "Samples appear after the task runs; or click Collect now. Chart auto-refreshes.",
|
||||||
waitAfterError: "Last collect failed — fix connectivity, then Collect now.",
|
waitAfterError: "Last collect failed — fix connectivity, then Collect now.",
|
||||||
|
kpiLatest: "latest",
|
||||||
|
kpiBw: "Bandwidth",
|
||||||
|
latestAt: "Latest sample",
|
||||||
|
hoverPoint: "Cursor",
|
||||||
kpi: {
|
kpi: {
|
||||||
tasks: "Tasks",
|
tasks: "Tasks",
|
||||||
running: "Running",
|
running: "Running",
|
||||||
|
|
|
||||||
|
|
@ -109,6 +109,10 @@ const zh = {
|
||||||
targetError: "接口最近错误",
|
targetError: "接口最近错误",
|
||||||
waitSamples: "任务运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。",
|
waitSamples: "任务运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。",
|
||||||
waitAfterError: "采集失败,请检查连通性后点「立即采集」重试。",
|
waitAfterError: "采集失败,请检查连通性后点「立即采集」重试。",
|
||||||
|
kpiLatest: "最新",
|
||||||
|
kpiBw: "带宽",
|
||||||
|
latestAt: "最新样点",
|
||||||
|
hoverPoint: "光标位置",
|
||||||
kpi: {
|
kpi: {
|
||||||
tasks: "任务数",
|
tasks: "任务数",
|
||||||
running: "运行中",
|
running: "运行中",
|
||||||
|
|
|
||||||
|
|
@ -3087,21 +3087,64 @@ pre {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pt-wall__chart .uplot,
|
||||||
|
.pt-wall__chart .u-wrap {
|
||||||
|
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
|
||||||
|
}
|
||||||
|
|
||||||
.pt-wall__chart-wrap {
|
.pt-wall__chart-wrap {
|
||||||
position: relative;
|
position: relative;
|
||||||
min-height: 260px;
|
min-height: 260px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pt-wall__chart .uplot,
|
.pt-wall__tip {
|
||||||
.pt-wall__chart .u-wrap {
|
position: absolute;
|
||||||
font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace;
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 5;
|
||||||
|
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);
|
||||||
|
color: #e2e8f0;
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
|
pointer-events: none;
|
||||||
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__tip-time {
|
||||||
|
margin-bottom: 6px;
|
||||||
|
color: #94a3b8;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__tip-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__tip-row--in strong {
|
||||||
|
color: #2dd4bf;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pt-wall__tip-row--out strong {
|
||||||
|
color: #f59e0b;
|
||||||
}
|
}
|
||||||
|
|
||||||
.pt-wall__chart .u-legend {
|
.pt-wall__chart .u-legend {
|
||||||
color: var(--pt-muted);
|
color: var(--pt-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.pt-wall__chart .u-legend .u-value {
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
.pt-wall__empty {
|
.pt-wall__empty {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,9 @@
|
||||||
import { useEffect, useMemo, useRef } from "react";
|
import { useEffect, useMemo, useRef, useState } from "react";
|
||||||
import uPlot from "uplot";
|
import uPlot from "uplot";
|
||||||
import "uplot/dist/uPlot.min.css";
|
import "uplot/dist/uPlot.min.css";
|
||||||
import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types";
|
import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types";
|
||||||
import { formatSystemTime, parseApiTime } from "../../utils/time";
|
import { formatSystemTime, parseApiTime } from "../../utils/time";
|
||||||
|
import { useI18n } from "../../i18n";
|
||||||
|
|
||||||
function formatBps(n: number): string {
|
function formatBps(n: number): string {
|
||||||
const v = Math.abs(n);
|
const v = Math.abs(n);
|
||||||
|
|
@ -17,6 +18,10 @@ function formatBwLabel(bps: number): string {
|
||||||
return `${formatBps(bps)}bit/s`;
|
return `${formatBps(bps)}bit/s`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatHoverTime(sec: number): string {
|
||||||
|
return formatSystemTime(new Date(sec * 1000));
|
||||||
|
}
|
||||||
|
|
||||||
function toSeries(points: PortTrafficSamplePoint[]): [number[], number[], number[]] {
|
function toSeries(points: PortTrafficSamplePoint[]): [number[], number[], number[]] {
|
||||||
const xs: number[] = [];
|
const xs: number[] = [];
|
||||||
const ins: number[] = [];
|
const ins: number[] = [];
|
||||||
|
|
@ -28,7 +33,6 @@ function toSeries(points: PortTrafficSamplePoint[]): [number[], number[], number
|
||||||
ins.push(Number(p.in_bps) || 0);
|
ins.push(Number(p.in_bps) || 0);
|
||||||
outs.push(Number(p.out_bps) || 0);
|
outs.push(Number(p.out_bps) || 0);
|
||||||
}
|
}
|
||||||
// Single sample: stretch a short segment so the line is visible.
|
|
||||||
if (xs.length === 1) {
|
if (xs.length === 1) {
|
||||||
xs.push(xs[0] + 60);
|
xs.push(xs[0] + 60);
|
||||||
ins.push(ins[0]);
|
ins.push(ins[0]);
|
||||||
|
|
@ -43,6 +47,14 @@ function chartSize(el: HTMLElement): { width: number; height: number } {
|
||||||
return { width, height };
|
return { width, height };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type HoverKpi = {
|
||||||
|
tsSec: number;
|
||||||
|
inBps: number;
|
||||||
|
outBps: number;
|
||||||
|
inUtil: number;
|
||||||
|
outUtil: number;
|
||||||
|
};
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
target: PortTrafficTarget | null;
|
target: PortTrafficTarget | null;
|
||||||
points: PortTrafficSamplePoint[];
|
points: PortTrafficSamplePoint[];
|
||||||
|
|
@ -52,11 +64,18 @@ type Props = {
|
||||||
};
|
};
|
||||||
|
|
||||||
export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: Props) {
|
export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: Props) {
|
||||||
|
const { t } = useI18n();
|
||||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||||
const plotRef = useRef<uPlot | null>(null);
|
const plotRef = useRef<uPlot | null>(null);
|
||||||
|
const tipRef = useRef<HTMLDivElement | null>(null);
|
||||||
|
const pointsRef = useRef(points);
|
||||||
|
const hoverIdxRef = useRef<number | null>(null);
|
||||||
|
const [hover, setHover] = useState<HoverKpi | null>(null);
|
||||||
|
|
||||||
|
pointsRef.current = points;
|
||||||
|
|
||||||
const latest = points.length ? points[points.length - 1] : null;
|
const latest = points.length ? points[points.length - 1] : null;
|
||||||
const kpi = useMemo(() => {
|
const latestKpi = useMemo(() => {
|
||||||
const bw = latest?.bw_bps || target?.bw_bps || 0;
|
const bw = latest?.bw_bps || target?.bw_bps || 0;
|
||||||
return {
|
return {
|
||||||
bw,
|
bw,
|
||||||
|
|
@ -64,33 +83,70 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
|
||||||
outBps: latest?.out_bps ?? 0,
|
outBps: latest?.out_bps ?? 0,
|
||||||
inUtil: latest?.in_util_pct ?? 0,
|
inUtil: latest?.in_util_pct ?? 0,
|
||||||
outUtil: latest?.out_util_pct ?? 0,
|
outUtil: latest?.out_util_pct ?? 0,
|
||||||
|
ts: latest?.ts ?? null,
|
||||||
};
|
};
|
||||||
}, [latest, target]);
|
}, [latest, target]);
|
||||||
|
|
||||||
// Create plot once; keep resize listener for lifetime of mount.
|
const kpi = hover
|
||||||
|
? {
|
||||||
|
bw: latestKpi.bw,
|
||||||
|
inBps: hover.inBps,
|
||||||
|
outBps: hover.outBps,
|
||||||
|
inUtil: hover.inUtil,
|
||||||
|
outUtil: hover.outUtil,
|
||||||
|
hovering: true as const,
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
...latestKpi,
|
||||||
|
hovering: false as const,
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const el = mountRef.current;
|
const el = mountRef.current;
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
|
|
||||||
const { width, height } = chartSize(el);
|
const { width, height } = chartSize(el);
|
||||||
|
const valueFmt = (_u: uPlot, v: number | null | undefined) =>
|
||||||
|
v == null || Number.isNaN(v) ? "—" : `${formatBps(v)} bit/s`;
|
||||||
|
|
||||||
|
const placeTip = (u: uPlot, left: number, top: number) => {
|
||||||
|
const tip = tipRef.current;
|
||||||
|
if (!tip) return;
|
||||||
|
const wrap = tip.parentElement;
|
||||||
|
if (!wrap) return;
|
||||||
|
const overRect = u.over.getBoundingClientRect();
|
||||||
|
const wrapRect = wrap.getBoundingClientRect();
|
||||||
|
const tipW = tip.offsetWidth || 160;
|
||||||
|
const tipH = tip.offsetHeight || 72;
|
||||||
|
let x = overRect.left - wrapRect.left + left + 14;
|
||||||
|
let y = overRect.top - wrapRect.top + top - tipH - 8;
|
||||||
|
x = Math.min(Math.max(8, x), Math.max(8, wrapRect.width - tipW - 8));
|
||||||
|
y = Math.max(8, y);
|
||||||
|
tip.style.transform = `translate(${x}px, ${y}px)`;
|
||||||
|
};
|
||||||
|
|
||||||
const opts: uPlot.Options = {
|
const opts: uPlot.Options = {
|
||||||
width,
|
width,
|
||||||
height,
|
height,
|
||||||
series: [
|
series: [
|
||||||
{},
|
|
||||||
{
|
{
|
||||||
label: "In bit/s",
|
value: (_u, raw) => (raw == null ? "—" : formatHoverTime(Number(raw))),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "In",
|
||||||
stroke: "#2dd4bf",
|
stroke: "#2dd4bf",
|
||||||
width: 2,
|
width: 2,
|
||||||
fill: "rgba(45, 212, 191, 0.12)",
|
fill: "rgba(45, 212, 191, 0.12)",
|
||||||
points: { show: true, size: 5, fill: "#2dd4bf" },
|
points: { show: true, size: 5, fill: "#2dd4bf" },
|
||||||
|
value: valueFmt,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: "Out bit/s",
|
label: "Out",
|
||||||
stroke: "#f59e0b",
|
stroke: "#f59e0b",
|
||||||
width: 2,
|
width: 2,
|
||||||
fill: "rgba(245, 158, 11, 0.10)",
|
fill: "rgba(245, 158, 11, 0.10)",
|
||||||
points: { show: true, size: 5, fill: "#f59e0b" },
|
points: { show: true, size: 5, fill: "#f59e0b" },
|
||||||
|
value: valueFmt,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
axes: [
|
axes: [
|
||||||
|
|
@ -124,11 +180,73 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
legend: { show: true },
|
legend: { show: true, live: true },
|
||||||
cursor: { drag: { x: true, y: false } },
|
cursor: {
|
||||||
|
drag: { x: true, y: false },
|
||||||
|
focus: { prox: 32 },
|
||||||
|
points: { show: true, size: 8 },
|
||||||
|
},
|
||||||
|
hooks: {
|
||||||
|
setCursor: [
|
||||||
|
(u) => {
|
||||||
|
const tip = tipRef.current;
|
||||||
|
const idx = u.cursor.idx;
|
||||||
|
const left = u.cursor.left ?? 0;
|
||||||
|
const top = u.cursor.top ?? 0;
|
||||||
|
|
||||||
|
if (idx == null || idx < 0 || !u.data[0]?.length) {
|
||||||
|
if (hoverIdxRef.current !== null) {
|
||||||
|
hoverIdxRef.current = null;
|
||||||
|
setHover(null);
|
||||||
|
}
|
||||||
|
if (tip) tip.style.display = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const tsSec = Number(u.data[0][idx]);
|
||||||
|
const inBps = Number(u.data[1]?.[idx] ?? 0);
|
||||||
|
const outBps = Number(u.data[2]?.[idx] ?? 0);
|
||||||
|
const sample = pointsRef.current.find((p) => {
|
||||||
|
const d = parseApiTime(p.ts);
|
||||||
|
return d && Math.floor(d.getTime() / 1000) === tsSec;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (tip) {
|
||||||
|
tip.style.display = "block";
|
||||||
|
tip.innerHTML = [
|
||||||
|
`<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>`,
|
||||||
|
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("");
|
||||||
|
placeTip(u, left, top);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hoverIdxRef.current === idx) return;
|
||||||
|
hoverIdxRef.current = idx;
|
||||||
|
setHover({
|
||||||
|
tsSec,
|
||||||
|
inBps,
|
||||||
|
outBps,
|
||||||
|
inUtil: Number(sample?.in_util_pct ?? 0),
|
||||||
|
outUtil: Number(sample?.out_util_pct ?? 0),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
plotRef.current = new uPlot(opts, [[], [], []], el);
|
plotRef.current = new uPlot(opts, [[], [], []], el);
|
||||||
|
|
||||||
|
const onLeave = () => {
|
||||||
|
hoverIdxRef.current = null;
|
||||||
|
setHover(null);
|
||||||
|
if (tipRef.current) tipRef.current.style.display = "none";
|
||||||
|
};
|
||||||
|
plotRef.current.over.addEventListener("mouseleave", onLeave);
|
||||||
|
|
||||||
const onResize = () => {
|
const onResize = () => {
|
||||||
if (!plotRef.current || !mountRef.current) return;
|
if (!plotRef.current || !mountRef.current) return;
|
||||||
plotRef.current.setSize(chartSize(mountRef.current));
|
plotRef.current.setSize(chartSize(mountRef.current));
|
||||||
|
|
@ -136,17 +254,16 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
|
||||||
window.addEventListener("resize", onResize);
|
window.addEventListener("resize", onResize);
|
||||||
return () => {
|
return () => {
|
||||||
window.removeEventListener("resize", onResize);
|
window.removeEventListener("resize", onResize);
|
||||||
|
plotRef.current?.over.removeEventListener("mouseleave", onLeave);
|
||||||
plotRef.current?.destroy();
|
plotRef.current?.destroy();
|
||||||
plotRef.current = null;
|
plotRef.current = null;
|
||||||
};
|
};
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Push live samples into the existing chart.
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const plot = plotRef.current;
|
const plot = plotRef.current;
|
||||||
if (!plot) return;
|
if (!plot) return;
|
||||||
const data = toSeries(points);
|
plot.setData(toSeries(points));
|
||||||
plot.setData(data);
|
|
||||||
if (mountRef.current) plot.setSize(chartSize(mountRef.current));
|
if (mountRef.current) plot.setSize(chartSize(mountRef.current));
|
||||||
}, [points]);
|
}, [points]);
|
||||||
|
|
||||||
|
|
@ -161,30 +278,40 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-wall__range">
|
<div className="pt-wall__range">
|
||||||
{rangeLabel}
|
{rangeLabel}
|
||||||
{latest?.ts ? ` · last ${formatSystemTime(latest.ts)}` : ""}
|
{kpi.hovering
|
||||||
|
? ` · ${t("portTraffic.hoverPoint")}`
|
||||||
|
: latestKpi.ts
|
||||||
|
? ` · ${t("portTraffic.latestAt")} ${formatSystemTime(latestKpi.ts)}`
|
||||||
|
: ""}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-wall__kpis">
|
<div className="pt-wall__kpis">
|
||||||
<div className="pt-wall__kpi">
|
<div className="pt-wall__kpi">
|
||||||
<div className="pt-wall__kpi-label">Bandwidth</div>
|
<div className="pt-wall__kpi-label">{t("portTraffic.kpiBw")}</div>
|
||||||
<div className="pt-wall__kpi-value">{formatBwLabel(kpi.bw)}</div>
|
<div className="pt-wall__kpi-value">{formatBwLabel(kpi.bw)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-wall__kpi pt-wall__kpi--in">
|
<div className="pt-wall__kpi pt-wall__kpi--in">
|
||||||
<div className="pt-wall__kpi-label">In</div>
|
<div className="pt-wall__kpi-label">
|
||||||
|
In · {kpi.hovering ? t("portTraffic.hoverPoint") : t("portTraffic.kpiLatest")}
|
||||||
|
</div>
|
||||||
<div className="pt-wall__kpi-value">
|
<div className="pt-wall__kpi-value">
|
||||||
{formatBps(kpi.inBps)}
|
{formatBps(kpi.inBps)}
|
||||||
<span className="pt-wall__kpi-unit">bit/s</span>
|
<span className="pt-wall__kpi-unit">bit/s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-wall__kpi pt-wall__kpi--out">
|
<div className="pt-wall__kpi pt-wall__kpi--out">
|
||||||
<div className="pt-wall__kpi-label">Out</div>
|
<div className="pt-wall__kpi-label">
|
||||||
|
Out · {kpi.hovering ? t("portTraffic.hoverPoint") : t("portTraffic.kpiLatest")}
|
||||||
|
</div>
|
||||||
<div className="pt-wall__kpi-value">
|
<div className="pt-wall__kpi-value">
|
||||||
{formatBps(kpi.outBps)}
|
{formatBps(kpi.outBps)}
|
||||||
<span className="pt-wall__kpi-unit">bit/s</span>
|
<span className="pt-wall__kpi-unit">bit/s</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-wall__kpi">
|
<div className="pt-wall__kpi">
|
||||||
<div className="pt-wall__kpi-label">Util In / Out</div>
|
<div className="pt-wall__kpi-label">
|
||||||
|
Util · {kpi.hovering ? t("portTraffic.hoverPoint") : t("portTraffic.kpiLatest")}
|
||||||
|
</div>
|
||||||
<div className="pt-wall__kpi-value">
|
<div className="pt-wall__kpi-value">
|
||||||
{kpi.inUtil.toFixed(1)}%
|
{kpi.inUtil.toFixed(1)}%
|
||||||
<span className="pt-wall__kpi-sep">/</span>
|
<span className="pt-wall__kpi-sep">/</span>
|
||||||
|
|
@ -194,6 +321,7 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
|
||||||
</div>
|
</div>
|
||||||
<div className="pt-wall__chart-wrap">
|
<div className="pt-wall__chart-wrap">
|
||||||
<div className="pt-wall__chart" ref={mountRef} />
|
<div className="pt-wall__chart" ref={mountRef} />
|
||||||
|
<div className="pt-wall__tip" ref={tipRef} style={{ display: "none" }} />
|
||||||
{empty ? (
|
{empty ? (
|
||||||
<div className="pt-wall__empty">
|
<div className="pt-wall__empty">
|
||||||
{loading ? "Loading samples…" : hint || "Waiting for samples…"}
|
{loading ? "Loading samples…" : hint || "Waiting for samples…"}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue