diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index fa2d443..34f9055 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -109,6 +109,10 @@ const en = { 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.", + kpiLatest: "latest", + kpiBw: "Bandwidth", + latestAt: "Latest sample", + hoverPoint: "Cursor", kpi: { tasks: "Tasks", running: "Running", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 46785a9..66c8530 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -109,6 +109,10 @@ const zh = { targetError: "接口最近错误", waitSamples: "任务运行后会按周期写入样点;也可点「立即采集」。图会每几秒自动刷新。", waitAfterError: "采集失败,请检查连通性后点「立即采集」重试。", + kpiLatest: "最新", + kpiBw: "带宽", + latestAt: "最新样点", + hoverPoint: "光标位置", kpi: { tasks: "任务数", running: "运行中", diff --git a/web/src/index.css b/web/src/index.css index dd5b303..5034363 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -3087,21 +3087,64 @@ pre { width: 100%; } +.pt-wall__chart .uplot, +.pt-wall__chart .u-wrap { + font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace; +} + .pt-wall__chart-wrap { position: relative; min-height: 260px; width: 100%; } -.pt-wall__chart .uplot, -.pt-wall__chart .u-wrap { - font-family: "IBM Plex Mono", "Cascadia Mono", Consolas, monospace; +.pt-wall__tip { + position: absolute; + 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 { color: var(--pt-muted); } +.pt-wall__chart .u-legend .u-value { + font-variant-numeric: tabular-nums; + color: #e2e8f0; +} + .pt-wall__empty { position: absolute; inset: 0; diff --git a/web/src/pages/network/PortTrafficWall.tsx b/web/src/pages/network/PortTrafficWall.tsx index c92f79d..88cdd51 100644 --- a/web/src/pages/network/PortTrafficWall.tsx +++ b/web/src/pages/network/PortTrafficWall.tsx @@ -1,8 +1,9 @@ -import { useEffect, useMemo, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import uPlot from "uplot"; import "uplot/dist/uPlot.min.css"; import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types"; import { formatSystemTime, parseApiTime } from "../../utils/time"; +import { useI18n } from "../../i18n"; function formatBps(n: number): string { const v = Math.abs(n); @@ -17,6 +18,10 @@ function formatBwLabel(bps: number): string { return `${formatBps(bps)}bit/s`; } +function formatHoverTime(sec: number): string { + return formatSystemTime(new Date(sec * 1000)); +} + function toSeries(points: PortTrafficSamplePoint[]): [number[], number[], number[]] { const xs: number[] = []; const ins: number[] = []; @@ -28,7 +33,6 @@ function toSeries(points: PortTrafficSamplePoint[]): [number[], number[], number 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]); @@ -43,6 +47,14 @@ function chartSize(el: HTMLElement): { width: number; height: number } { return { width, height }; } +type HoverKpi = { + tsSec: number; + inBps: number; + outBps: number; + inUtil: number; + outUtil: number; +}; + type Props = { target: PortTrafficTarget | null; points: PortTrafficSamplePoint[]; @@ -52,11 +64,18 @@ type Props = { }; export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: Props) { + const { t } = useI18n(); const mountRef = useRef(null); const plotRef = useRef(null); + const tipRef = useRef(null); + const pointsRef = useRef(points); + const hoverIdxRef = useRef(null); + const [hover, setHover] = useState(null); + + pointsRef.current = points; const latest = points.length ? points[points.length - 1] : null; - const kpi = useMemo(() => { + const latestKpi = useMemo(() => { const bw = latest?.bw_bps || target?.bw_bps || 0; return { bw, @@ -64,33 +83,70 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P outBps: latest?.out_bps ?? 0, inUtil: latest?.in_util_pct ?? 0, outUtil: latest?.out_util_pct ?? 0, + ts: latest?.ts ?? null, }; }, [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(() => { const el = mountRef.current; if (!el) return; 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 = { width, height, series: [ - {}, { - label: "In bit/s", + value: (_u, raw) => (raw == null ? "—" : formatHoverTime(Number(raw))), + }, + { + label: "In", stroke: "#2dd4bf", width: 2, fill: "rgba(45, 212, 191, 0.12)", points: { show: true, size: 5, fill: "#2dd4bf" }, + value: valueFmt, }, { - label: "Out bit/s", + label: "Out", stroke: "#f59e0b", width: 2, fill: "rgba(245, 158, 11, 0.10)", points: { show: true, size: 5, fill: "#f59e0b" }, + value: valueFmt, }, ], axes: [ @@ -124,11 +180,73 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P }, }, }, - legend: { show: true }, - cursor: { drag: { x: true, y: false } }, + legend: { show: true, live: true }, + 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 = [ + `
${formatHoverTime(tsSec)}
`, + `
In${formatBps(inBps)} bit/s
`, + `
Out${formatBps(outBps)} bit/s
`, + sample + ? `
Util${Number(sample.in_util_pct || 0).toFixed(1)}% / ${Number(sample.out_util_pct || 0).toFixed(1)}%
` + : "", + ].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); + + const onLeave = () => { + hoverIdxRef.current = null; + setHover(null); + if (tipRef.current) tipRef.current.style.display = "none"; + }; + plotRef.current.over.addEventListener("mouseleave", onLeave); + const onResize = () => { if (!plotRef.current || !mountRef.current) return; plotRef.current.setSize(chartSize(mountRef.current)); @@ -136,17 +254,16 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P window.addEventListener("resize", onResize); return () => { window.removeEventListener("resize", onResize); + plotRef.current?.over.removeEventListener("mouseleave", onLeave); 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); + plot.setData(toSeries(points)); if (mountRef.current) plot.setSize(chartSize(mountRef.current)); }, [points]); @@ -161,30 +278,40 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
{rangeLabel} - {latest?.ts ? ` · last ${formatSystemTime(latest.ts)}` : ""} + {kpi.hovering + ? ` · ${t("portTraffic.hoverPoint")}` + : latestKpi.ts + ? ` · ${t("portTraffic.latestAt")} ${formatSystemTime(latestKpi.ts)}` + : ""}
-
Bandwidth
+
{t("portTraffic.kpiBw")}
{formatBwLabel(kpi.bw)}
-
In
+
+ In · {kpi.hovering ? t("portTraffic.hoverPoint") : t("portTraffic.kpiLatest")} +
{formatBps(kpi.inBps)} bit/s
-
Out
+
+ Out · {kpi.hovering ? t("portTraffic.hoverPoint") : t("portTraffic.kpiLatest")} +
{formatBps(kpi.outBps)} bit/s
-
Util In / Out
+
+ Util · {kpi.hovering ? t("portTraffic.hoverPoint") : t("portTraffic.kpiLatest")} +
{kpi.inUtil.toFixed(1)}% / @@ -194,6 +321,7 @@ export function PortTrafficWall({ target, points, rangeLabel, loading, hint }: P
+
{empty ? (
{loading ? "Loading samples…" : hint || "Waiting for samples…"}