mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 00:35:47 +08:00
Move port traffic to device-centric monitoring with a dedicated wall and collect logs.
Split the ops wall to its own route with fullscreen charts, persist collect errors as device events, and polish wall legend layout. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
5079343b2a
commit
214bc56774
24 changed files with 3309 additions and 1357 deletions
|
|
@ -104,7 +104,10 @@ export function NetworkLayout() {
|
|||
className={({ isActive }) =>
|
||||
`network-nav__link${isActive ? " is-active" : ""}`
|
||||
}
|
||||
end={item.path === "/network/devices"}
|
||||
end={
|
||||
item.path === "/network/devices" ||
|
||||
item.path === "/network/tasks/port-traffic"
|
||||
}
|
||||
>
|
||||
{t(item.labelKey)}
|
||||
</NavLink>
|
||||
|
|
@ -127,7 +130,10 @@ export function NetworkLayout() {
|
|||
className={({ isActive }) =>
|
||||
`network-nav__rail-link${isActive ? " is-active" : ""}`
|
||||
}
|
||||
end={item.path === "/network/devices"}
|
||||
end={
|
||||
item.path === "/network/devices" ||
|
||||
item.path === "/network/tasks/port-traffic"
|
||||
}
|
||||
>
|
||||
{t(item.labelKey).slice(0, 1)}
|
||||
</NavLink>
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -5,6 +5,8 @@ import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types";
|
|||
import { formatSystemTime, parseApiTime } from "../../utils/time";
|
||||
import { useI18n } from "../../i18n";
|
||||
|
||||
export type WallYMode = "auto" | "current" | "util";
|
||||
|
||||
function formatBps(n: number): string {
|
||||
const v = Math.abs(n);
|
||||
if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`;
|
||||
|
|
@ -18,13 +20,59 @@ function formatBwLabel(bps: number): string {
|
|||
return `${formatBps(bps)}bit/s`;
|
||||
}
|
||||
|
||||
function formatUtilPct(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return "0%";
|
||||
if (n < 0.01) return "<0.01%";
|
||||
if (n < 1) return `${n.toFixed(2)}%`;
|
||||
return `${n.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function resolveUtil(vendorUtil: number, bps: number, bw: number): number {
|
||||
if (vendorUtil > 0) return vendorUtil;
|
||||
if (bw > 0 && bps > 0) return (bps / bw) * 100;
|
||||
return vendorUtil || 0;
|
||||
}
|
||||
|
||||
function formatHoverTime(sec: number): string {
|
||||
return formatSystemTime(new Date(sec * 1000));
|
||||
}
|
||||
|
||||
function yPadRange(min: number, max: number): [number, number] {
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
|
||||
if (min === max) {
|
||||
const pad = Math.max(max * 0.25 || 0.1, max > 0 && max < 1 ? max * 0.5 : 10);
|
||||
return [Math.max(0, min - pad * 0.1), max + pad];
|
||||
}
|
||||
return [Math.max(0, min * 0.95), max * 1.05];
|
||||
}
|
||||
|
||||
function finiteMax(vals: number[]): number {
|
||||
let m = Number.NEGATIVE_INFINITY;
|
||||
for (const v of vals) {
|
||||
if (Number.isFinite(v) && v > m) m = v;
|
||||
}
|
||||
return Number.isFinite(m) ? m : 0;
|
||||
}
|
||||
|
||||
function pointValue(
|
||||
p: PortTrafficSamplePoint,
|
||||
dir: "in" | "out",
|
||||
mode: WallYMode,
|
||||
bwFallback: number,
|
||||
): number {
|
||||
const bps = dir === "in" ? Number(p.in_bps) || 0 : Number(p.out_bps) || 0;
|
||||
if (mode !== "util") return bps;
|
||||
const bw = Number(p.bw_bps) || bwFallback || 0;
|
||||
const vendor = dir === "in" ? Number(p.in_util_pct) || 0 : Number(p.out_util_pct) || 0;
|
||||
return resolveUtil(vendor, bps, bw);
|
||||
}
|
||||
|
||||
function toAlignedSeries(
|
||||
current: PortTrafficSamplePoint[],
|
||||
baseline: PortTrafficSamplePoint[],
|
||||
mode: WallYMode,
|
||||
bwFallback: number,
|
||||
baseBwFallback: number,
|
||||
): [number[], number[], number[], number[], number[]] {
|
||||
type Row = { x: number; inC?: number; outC?: number; inB?: number; outB?: number };
|
||||
const map = new Map<number, Row>();
|
||||
|
|
@ -33,8 +81,8 @@ function toAlignedSeries(
|
|||
if (!d) continue;
|
||||
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;
|
||||
row.inC = pointValue(p, "in", mode, bwFallback);
|
||||
row.outC = pointValue(p, "out", mode, bwFallback);
|
||||
map.set(x, row);
|
||||
}
|
||||
for (const p of baseline) {
|
||||
|
|
@ -42,8 +90,8 @@ function toAlignedSeries(
|
|||
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;
|
||||
row.inB = pointValue(p, "in", mode, baseBwFallback || bwFallback);
|
||||
row.outB = pointValue(p, "out", mode, baseBwFallback || bwFallback);
|
||||
map.set(x, row);
|
||||
}
|
||||
const rows = [...map.values()].sort((a, b) => a.x - b.x);
|
||||
|
|
@ -61,7 +109,12 @@ function toAlignedSeries(
|
|||
|
||||
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)));
|
||||
const parentH = el.clientHeight || el.parentElement?.clientHeight || 0;
|
||||
// Fill staged layout / fullscreen; fall back to aspect ratio in compact panels.
|
||||
const height =
|
||||
parentH >= 180
|
||||
? Math.max(180, parentH)
|
||||
: Math.max(260, Math.min(440, Math.floor(width * 0.34)));
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
|
|
@ -73,6 +126,7 @@ type Props = {
|
|||
points: PortTrafficSamplePoint[];
|
||||
baselinePoints?: PortTrafficSamplePoint[];
|
||||
rangeLabel: string;
|
||||
yMode?: WallYMode;
|
||||
loading?: boolean;
|
||||
hint?: string;
|
||||
};
|
||||
|
|
@ -83,6 +137,7 @@ export function PortTrafficWall({
|
|||
points,
|
||||
baselinePoints = EMPTY_POINTS,
|
||||
rangeLabel,
|
||||
yMode = "auto",
|
||||
loading,
|
||||
hint,
|
||||
}: Props) {
|
||||
|
|
@ -92,21 +147,27 @@ export function PortTrafficWall({
|
|||
const tipRef = useRef<HTMLDivElement | null>(null);
|
||||
const pointsRef = useRef(points);
|
||||
const baselineRef = useRef(baselinePoints);
|
||||
const yModeRef = useRef(yMode);
|
||||
const bwRef = useRef(0);
|
||||
pointsRef.current = points;
|
||||
baselineRef.current = baselinePoints;
|
||||
yModeRef.current = yMode;
|
||||
|
||||
const latest = points.length ? points[points.length - 1] : null;
|
||||
const kpi = useMemo(() => {
|
||||
const bw = latest?.bw_bps || target?.bw_bps || 0;
|
||||
const inBps = latest?.in_bps ?? 0;
|
||||
const outBps = latest?.out_bps ?? 0;
|
||||
return {
|
||||
bw,
|
||||
inBps: latest?.in_bps ?? 0,
|
||||
outBps: latest?.out_bps ?? 0,
|
||||
inUtil: latest?.in_util_pct ?? 0,
|
||||
outUtil: latest?.out_util_pct ?? 0,
|
||||
inBps,
|
||||
outBps,
|
||||
inUtil: resolveUtil(latest?.in_util_pct ?? 0, inBps, bw),
|
||||
outUtil: resolveUtil(latest?.out_util_pct ?? 0, outBps, bw),
|
||||
ts: latest?.ts ?? null,
|
||||
};
|
||||
}, [latest, target]);
|
||||
bwRef.current = kpi.bw;
|
||||
|
||||
useEffect(() => {
|
||||
const el = mountRef.current;
|
||||
|
|
@ -139,16 +200,16 @@ export function PortTrafficWall({
|
|||
label: t("portTraffic.seriesCurrentIn"),
|
||||
stroke: "#2dd4bf",
|
||||
width: 2,
|
||||
fill: "rgba(45, 212, 191, 0.12)",
|
||||
points: { show: true, size: 5, fill: "#2dd4bf" },
|
||||
fill: "rgba(45, 212, 191, 0.08)",
|
||||
points: { show: false },
|
||||
spanGaps: false,
|
||||
},
|
||||
{
|
||||
label: t("portTraffic.seriesCurrentOut"),
|
||||
stroke: "#f59e0b",
|
||||
stroke: "#fbbf24",
|
||||
width: 2,
|
||||
fill: "rgba(245, 158, 11, 0.10)",
|
||||
points: { show: true, size: 5, fill: "#f59e0b" },
|
||||
fill: "rgba(251, 191, 36, 0.07)",
|
||||
points: { show: false },
|
||||
spanGaps: false,
|
||||
},
|
||||
{
|
||||
|
|
@ -162,7 +223,7 @@ export function PortTrafficWall({
|
|||
},
|
||||
{
|
||||
label: t("portTraffic.seriesBaselineOut"),
|
||||
stroke: "rgba(245, 158, 11, 0.45)",
|
||||
stroke: "rgba(251, 191, 36, 0.4)",
|
||||
width: 1.5,
|
||||
dash: [6, 4],
|
||||
points: { show: false },
|
||||
|
|
@ -173,16 +234,20 @@ export function PortTrafficWall({
|
|||
axes: [
|
||||
{
|
||||
stroke: "#94a3b8",
|
||||
grid: { stroke: "rgba(148,163,184,0.18)", width: 1 },
|
||||
ticks: { stroke: "rgba(148,163,184,0.35)" },
|
||||
grid: { show: false },
|
||||
ticks: { stroke: "rgba(148,163,184,0.35)", size: 4 },
|
||||
border: { show: false },
|
||||
gap: 6,
|
||||
},
|
||||
{
|
||||
stroke: "#94a3b8",
|
||||
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)),
|
||||
border: { show: false },
|
||||
values: (_u, splits) =>
|
||||
splits.map((v) => (yModeRef.current === "util" ? `${v}` : formatBps(v))),
|
||||
size: 64,
|
||||
label: "bit/s",
|
||||
label: yMode === "util" ? "%" : "bit/s",
|
||||
labelFont: "12px sans-serif",
|
||||
labelSize: 14,
|
||||
},
|
||||
|
|
@ -191,17 +256,19 @@ export function PortTrafficWall({
|
|||
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];
|
||||
range: (u, min, max) => {
|
||||
if (yModeRef.current === "current") {
|
||||
const vals = [...(u.data[1] || []), ...(u.data[2] || [])].filter((v) =>
|
||||
Number.isFinite(v),
|
||||
) as number[];
|
||||
if (!vals.length) return yPadRange(min, max);
|
||||
return yPadRange(0, finiteMax(vals));
|
||||
}
|
||||
return [Math.max(0, min * 0.95), max * 1.05];
|
||||
return yPadRange(min, max);
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: { show: true, live: false },
|
||||
legend: { show: false },
|
||||
cursor: {
|
||||
drag: { x: true, y: false },
|
||||
focus: { prox: 32 },
|
||||
|
|
@ -221,10 +288,6 @@ export function PortTrafficWall({
|
|||
}
|
||||
|
||||
const tsSec = Number(u.data[0][idx]);
|
||||
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;
|
||||
|
|
@ -233,26 +296,39 @@ export function PortTrafficWall({
|
|||
const d = parseApiTime(p.ts);
|
||||
return d && Math.floor(d.getTime() / 1000) === tsSec;
|
||||
});
|
||||
const mode = yModeRef.current;
|
||||
const yIn = Number(u.data[1]?.[idx]);
|
||||
const yOut = Number(u.data[2]?.[idx]);
|
||||
const yInB = Number(u.data[3]?.[idx]);
|
||||
const yOutB = Number(u.data[4]?.[idx]);
|
||||
const unit = mode === "util" ? "%" : "bit/s";
|
||||
const fmtY = (v: number) =>
|
||||
mode === "util" ? formatUtilPct(v).replace(/%$/, "") : formatBps(v);
|
||||
|
||||
if (!tip) return;
|
||||
tip.style.display = "block";
|
||||
const rows = [
|
||||
`<div class="pt-wall__tip-time">${formatHoverTime(tsSec)}</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(yIn)
|
||||
? `<div class="pt-wall__tip-row pt-wall__tip-row--in"><span>In</span><strong>${fmtY(yIn)} ${unit}</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>`
|
||||
Number.isFinite(yOut)
|
||||
? `<div class="pt-wall__tip-row pt-wall__tip-row--out"><span>Out</span><strong>${fmtY(yOut)} ${unit}</strong></div>`
|
||||
: "",
|
||||
];
|
||||
if (Number.isFinite(inBase) || Number.isFinite(outBase)) {
|
||||
if (sample) {
|
||||
const bw = Number(sample.bw_bps) || bwRef.current || 0;
|
||||
const inU = resolveUtil(Number(sample.in_util_pct) || 0, Number(sample.in_bps) || 0, bw);
|
||||
const outU = resolveUtil(Number(sample.out_util_pct) || 0, Number(sample.out_bps) || 0, bw);
|
||||
rows.push(
|
||||
`<div class="pt-wall__tip-row"><span>Util</span><strong>${formatUtilPct(inU)} / ${formatUtilPct(outU)}</strong></div>`,
|
||||
);
|
||||
}
|
||||
if (Number.isFinite(yInB) || Number.isFinite(yOutB)) {
|
||||
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>`,
|
||||
Number.isFinite(yInB) ? fmtY(yInB) : "—"
|
||||
} / ${Number.isFinite(yOutB) ? fmtY(yOutB) : "—"} ${unit}</strong></div>`,
|
||||
);
|
||||
if (baseSample?.ts_raw) {
|
||||
rows.push(
|
||||
|
|
@ -279,8 +355,16 @@ export function PortTrafficWall({
|
|||
plotRef.current.setSize(chartSize(mountRef.current));
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
const ro =
|
||||
typeof ResizeObserver !== "undefined"
|
||||
? new ResizeObserver(() => onResize())
|
||||
: null;
|
||||
ro?.observe(el);
|
||||
const wrap = el.parentElement;
|
||||
if (wrap) ro?.observe(wrap);
|
||||
return () => {
|
||||
window.removeEventListener("resize", onResize);
|
||||
ro?.disconnect();
|
||||
plotRef.current?.over.removeEventListener("mouseleave", onLeave);
|
||||
plotRef.current?.destroy();
|
||||
plotRef.current = null;
|
||||
|
|
@ -291,9 +375,9 @@ export function PortTrafficWall({
|
|||
useEffect(() => {
|
||||
const plot = plotRef.current;
|
||||
if (!plot) return;
|
||||
// Always setData with resetScales; toggling series.show before setData can leave
|
||||
// uPlot stuck on a blank [0,1] y-range until a later update.
|
||||
const data = toAlignedSeries(points, baselinePoints);
|
||||
const bw = kpi.bw || target?.bw_bps || 0;
|
||||
const baseBw = baselineTarget?.bw_bps || bw;
|
||||
const data = toAlignedSeries(points, baselinePoints, yMode, bw, baseBw);
|
||||
const showBaseline = baselinePoints.length > 0;
|
||||
plot.setData(data, true);
|
||||
const s3 = Boolean(plot.series[3]?.show);
|
||||
|
|
@ -302,15 +386,22 @@ export function PortTrafficWall({
|
|||
plot.setSeries(3, { show: showBaseline }, false);
|
||||
plot.setSeries(4, { show: showBaseline }, false);
|
||||
}
|
||||
const yAxis = plot.axes[1];
|
||||
if (yAxis) {
|
||||
const nextLabel = yMode === "util" ? "%" : "bit/s";
|
||||
if (yAxis.label !== nextLabel) {
|
||||
yAxis.label = nextLabel;
|
||||
plot.redraw(false, false);
|
||||
}
|
||||
}
|
||||
const el = mountRef.current;
|
||||
if (el) {
|
||||
// Legend show/hide changes layout; size after paint.
|
||||
requestAnimationFrame(() => {
|
||||
if (!plotRef.current || !mountRef.current) return;
|
||||
plotRef.current.setSize(chartSize(mountRef.current));
|
||||
});
|
||||
}
|
||||
}, [points, baselinePoints]);
|
||||
}, [points, baselinePoints, yMode, kpi.bw, target?.bw_bps, baselineTarget?.bw_bps]);
|
||||
|
||||
const empty = points.length === 0 && baselinePoints.length === 0;
|
||||
const neLabel = target ? target.ne_name || target.ne_ip || "—" : "—";
|
||||
|
|
@ -326,7 +417,15 @@ export function PortTrafficWall({
|
|||
return (
|
||||
<div className="pt-wall">
|
||||
<div className="pt-wall__head">
|
||||
<div className="pt-wall__head-title">{t("portTraffic.wallLiveTitle")}</div>
|
||||
<div className="pt-wall__title">
|
||||
<div className="pt-wall__ne">
|
||||
{neLabel}
|
||||
{ipLabel ? ` · ${ipLabel}` : ""}
|
||||
</div>
|
||||
<div className="pt-wall__if" title={ifLabel}>
|
||||
{ifLabel}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-wall__range">
|
||||
{rangeLabel}
|
||||
{kpi.ts ? ` · ${t("portTraffic.latestAt")} ${formatSystemTime(kpi.ts)}` : ""}
|
||||
|
|
@ -334,37 +433,29 @@ export function PortTrafficWall({
|
|||
</div>
|
||||
<div className="pt-wall__kpis">
|
||||
<div className="pt-wall__kpi">
|
||||
<div className="pt-wall__kpi-label">
|
||||
{t("portTraffic.kpiBw")} · {t("portTraffic.kpiLatest")}
|
||||
</div>
|
||||
<div className="pt-wall__kpi-label">{t("portTraffic.kpiBw")}</div>
|
||||
<div className="pt-wall__kpi-value">{formatBwLabel(kpi.bw)}</div>
|
||||
</div>
|
||||
<div className="pt-wall__kpi pt-wall__kpi--in">
|
||||
<div className="pt-wall__kpi-label">
|
||||
In · {t("portTraffic.kpiLatest")}
|
||||
</div>
|
||||
<div className="pt-wall__kpi-label">In</div>
|
||||
<div className="pt-wall__kpi-value">
|
||||
{formatBps(kpi.inBps)}
|
||||
<span className="pt-wall__kpi-unit">bit/s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-wall__kpi pt-wall__kpi--out">
|
||||
<div className="pt-wall__kpi-label">
|
||||
Out · {t("portTraffic.kpiLatest")}
|
||||
</div>
|
||||
<div className="pt-wall__kpi-label">Out</div>
|
||||
<div className="pt-wall__kpi-value">
|
||||
{formatBps(kpi.outBps)}
|
||||
<span className="pt-wall__kpi-unit">bit/s</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-wall__kpi">
|
||||
<div className="pt-wall__kpi-label">
|
||||
Util · {t("portTraffic.kpiLatest")}
|
||||
</div>
|
||||
<div className="pt-wall__kpi pt-wall__kpi--util">
|
||||
<div className="pt-wall__kpi-label">Util</div>
|
||||
<div className="pt-wall__kpi-value">
|
||||
{kpi.inUtil.toFixed(1)}%
|
||||
{formatUtilPct(kpi.inUtil)}
|
||||
<span className="pt-wall__kpi-sep">/</span>
|
||||
{kpi.outUtil.toFixed(1)}%
|
||||
{formatUtilPct(kpi.outUtil)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -378,17 +469,30 @@ export function PortTrafficWall({
|
|||
) : null}
|
||||
</div>
|
||||
<div className="pt-wall__foot">
|
||||
<div className="pt-wall__foot-ne" title={neLabel}>
|
||||
<span className="pt-wall__foot-label">{t("portTraffic.wallDevice")}</span>
|
||||
<span className="pt-wall__foot-value">{neLabel}</span>
|
||||
{ipLabel ? <span className="pt-wall__foot-ip">{ipLabel}</span> : null}
|
||||
</div>
|
||||
<div className="pt-wall__foot-if" title={ifLabel}>
|
||||
<span className="pt-wall__foot-label">{t("portTraffic.wallPort")}</span>
|
||||
<span className="pt-wall__foot-value pt-wall__foot-value--mono">{ifLabel}</span>
|
||||
<div className="pt-wall__legend" aria-hidden={!points.length && !baselinePoints.length}>
|
||||
<span className="pt-wall__legend-item pt-wall__legend-item--in">
|
||||
<i className="pt-wall__legend-swatch" />
|
||||
{t("portTraffic.seriesCurrentIn")}
|
||||
</span>
|
||||
<span className="pt-wall__legend-item pt-wall__legend-item--out">
|
||||
<i className="pt-wall__legend-swatch" />
|
||||
{t("portTraffic.seriesCurrentOut")}
|
||||
</span>
|
||||
{showBaselineMeta ? (
|
||||
<>
|
||||
<span className="pt-wall__legend-item pt-wall__legend-item--base-in">
|
||||
<i className="pt-wall__legend-swatch" />
|
||||
{t("portTraffic.seriesBaselineIn")}
|
||||
</span>
|
||||
<span className="pt-wall__legend-item pt-wall__legend-item--base-out">
|
||||
<i className="pt-wall__legend-swatch" />
|
||||
{t("portTraffic.seriesBaselineOut")}
|
||||
</span>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
{showBaselineMeta ? (
|
||||
<>
|
||||
<div className="pt-wall__foot-meta">
|
||||
<div className="pt-wall__foot-ne" title={baseNeLabel}>
|
||||
<span className="pt-wall__foot-label">{t("portTraffic.baselineDevice")}</span>
|
||||
<span className="pt-wall__foot-value">{baseNeLabel}</span>
|
||||
|
|
@ -398,7 +502,7 @@ export function PortTrafficWall({
|
|||
<span className="pt-wall__foot-label">{t("portTraffic.baselinePort")}</span>
|
||||
<span className="pt-wall__foot-value pt-wall__foot-value--mono">{baseIfLabel}</span>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
389
web/src/pages/network/PortTrafficWallPage.tsx
Normal file
389
web/src/pages/network/PortTrafficWallPage.tsx
Normal file
|
|
@ -0,0 +1,389 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
|
||||
import { Link, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
collectPortTrafficNow,
|
||||
fetchPortTrafficCompare,
|
||||
fetchPortTrafficDevices,
|
||||
fetchPortTrafficTargets,
|
||||
} from "../../services/api";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import type { PortTrafficDevice, PortTrafficSamplePoint } from "../../types";
|
||||
import { PortTrafficWall, type WallYMode } from "./PortTrafficWall";
|
||||
|
||||
const POLL_MS = 5000;
|
||||
const EMPTY_COMPARE_POINTS: PortTrafficSamplePoint[] = [];
|
||||
|
||||
type BaselineMode = "off" | "shift" | "day" | "week" | "custom";
|
||||
|
||||
function deviceLabel(d: PortTrafficDevice) {
|
||||
return d.ne_name || d.ne_ip || d.ne_id || "—";
|
||||
}
|
||||
|
||||
function FullscreenIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 14H5v5h5v-2H7v-3zm0-9h3V3H5v5h2V5zm12 9h-2v3h-3v2h5v-5zm-2-9V3h-3v2h3v3h2V5h-2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function PortTrafficWallPage() {
|
||||
const { t } = useI18n();
|
||||
const { showOk, showError } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const deepLinkAppliedRef = useRef(false);
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
const [fullscreen, setFullscreen] = useState(false);
|
||||
|
||||
const [wallDeviceId, setWallDeviceId] = useState(() => (searchParams.get("device_id") || "").trim());
|
||||
const [wallTargetId, setWallTargetId] = useState(() => (searchParams.get("target_id") || "").trim());
|
||||
const [mapBaselineDeviceId, setMapBaselineDeviceId] = useState("");
|
||||
const [mapBaselineTargetId, setMapBaselineTargetId] = useState("");
|
||||
const [rangeHours, setRangeHours] = useState(24);
|
||||
const [baseline, setBaseline] = useState<BaselineMode>("off");
|
||||
const [customOffsetHours, setCustomOffsetHours] = useState(48);
|
||||
const [yMode, setYMode] = useState<WallYMode>("auto");
|
||||
|
||||
const devicesQuery = useQuery({
|
||||
queryKey: queryKeys.portTrafficDevices(0),
|
||||
queryFn: () => fetchPortTrafficDevices({ page: 1, pageSize: 100 }),
|
||||
staleTime: 5000,
|
||||
refetchInterval: POLL_MS,
|
||||
});
|
||||
|
||||
const wallTargetsQuery = useQuery({
|
||||
queryKey: queryKeys.portTrafficTargets(wallDeviceId),
|
||||
queryFn: () => fetchPortTrafficTargets(wallDeviceId),
|
||||
enabled: Boolean(wallDeviceId),
|
||||
staleTime: 2000,
|
||||
refetchInterval: POLL_MS,
|
||||
});
|
||||
|
||||
const mapBaselineTargetsQuery = useQuery({
|
||||
queryKey: queryKeys.portTrafficTargets(mapBaselineDeviceId),
|
||||
queryFn: () => fetchPortTrafficTargets(mapBaselineDeviceId),
|
||||
enabled: Boolean(mapBaselineDeviceId),
|
||||
staleTime: 2000,
|
||||
refetchInterval: POLL_MS,
|
||||
});
|
||||
|
||||
const compareQuery = useQuery({
|
||||
queryKey: queryKeys.portTrafficCompare(
|
||||
wallTargetId,
|
||||
rangeHours,
|
||||
baseline,
|
||||
baseline === "custom" ? customOffsetHours : 0,
|
||||
mapBaselineTargetId,
|
||||
),
|
||||
queryFn: () =>
|
||||
fetchPortTrafficCompare({
|
||||
targetId: wallTargetId,
|
||||
rangeHours,
|
||||
baseline,
|
||||
offsetHours: baseline === "custom" ? customOffsetHours : undefined,
|
||||
baselineTargetId: mapBaselineTargetId || undefined,
|
||||
}),
|
||||
enabled: Boolean(wallTargetId),
|
||||
staleTime: 1000,
|
||||
placeholderData: keepPreviousData,
|
||||
refetchInterval: (q) => {
|
||||
const n = q.state.data?.current?.length ?? 0;
|
||||
return n === 0 ? 2500 : POLL_MS;
|
||||
},
|
||||
});
|
||||
|
||||
const devices = devicesQuery.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],
|
||||
);
|
||||
|
||||
const wallPoints = compareQuery.data?.current ?? EMPTY_COMPARE_POINTS;
|
||||
const wallBaselinePoints =
|
||||
baseline === "off" && !mapBaselineTargetId
|
||||
? EMPTY_COMPARE_POINTS
|
||||
: (compareQuery.data?.baseline ?? EMPTY_COMPARE_POINTS);
|
||||
|
||||
useEffect(() => {
|
||||
if (deepLinkAppliedRef.current) return;
|
||||
const did = (searchParams.get("device_id") || "").trim();
|
||||
const tid = (searchParams.get("target_id") || "").trim();
|
||||
deepLinkAppliedRef.current = true;
|
||||
if (did) setWallDeviceId(did);
|
||||
if (tid) setWallTargetId(tid);
|
||||
if (did || tid) setSearchParams({}, { replace: true });
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
useEffect(() => {
|
||||
if (wallDeviceId || !devices.length) return;
|
||||
setWallDeviceId(devices[0].id);
|
||||
}, [wallDeviceId, devices]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wallDeviceId || wallTargetId || !wallTargets.length) return;
|
||||
setWallTargetId(wallTargets[0].id);
|
||||
}, [wallDeviceId, wallTargetId, wallTargets]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mapBaselineTargetId) return;
|
||||
if (mapBaselineTargetId === wallTargetId) {
|
||||
setMapBaselineTargetId("");
|
||||
return;
|
||||
}
|
||||
if (
|
||||
mapBaselineDeviceId &&
|
||||
mapBaselineTargetsQuery.isFetched &&
|
||||
!mapBaselineOptions.some((x) => x.id === mapBaselineTargetId)
|
||||
) {
|
||||
setMapBaselineTargetId("");
|
||||
}
|
||||
}, [
|
||||
mapBaselineTargetId,
|
||||
wallTargetId,
|
||||
mapBaselineDeviceId,
|
||||
mapBaselineOptions,
|
||||
mapBaselineTargetsQuery.isFetched,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
const syncFs = () => {
|
||||
const el = stageRef.current;
|
||||
setFullscreen(Boolean(el && document.fullscreenElement === el));
|
||||
window.setTimeout(() => window.dispatchEvent(new Event("resize")), 80);
|
||||
};
|
||||
document.addEventListener("fullscreenchange", syncFs);
|
||||
return () => document.removeEventListener("fullscreenchange", syncFs);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
const el = stageRef.current;
|
||||
if (!el) return;
|
||||
try {
|
||||
if (document.fullscreenElement === el) {
|
||||
await document.exitFullscreen();
|
||||
} else {
|
||||
await el.requestFullscreen();
|
||||
}
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const collectMut = useMutation({
|
||||
mutationFn: collectPortTrafficNow,
|
||||
onSuccess: (res) => {
|
||||
showOk(res.started ? t("portTraffic.collectStarted") : t("portTraffic.collectBusy"));
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficDevicesAll });
|
||||
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTargets(wallDeviceId) });
|
||||
void queryClient.invalidateQueries({ queryKey: ["portTrafficCompare"] });
|
||||
},
|
||||
onError: (e: Error) => showError(e.message),
|
||||
});
|
||||
|
||||
return (
|
||||
<section className="pt-wall-shell">
|
||||
{!devices.length && !devicesQuery.isLoading ? (
|
||||
<div className="pt-list-empty pt-wall-shell__empty">
|
||||
<p>{t("portTraffic.wallEmpty")}</p>
|
||||
<Link to="/network/tasks/port-traffic" className="btn-primary" style={{ textDecoration: "none" }}>
|
||||
{t("portTraffic.create")}
|
||||
</Link>
|
||||
</div>
|
||||
) : (
|
||||
<div className="pt-wall-page">
|
||||
<div className="pt-wall-page__toolbar">
|
||||
<div className="pt-wall-page__row pt-wall-page__row--primary">
|
||||
<label className="pt-wall-page__field pt-wall-page__field--task">
|
||||
{t("portTraffic.wallDevice")}
|
||||
<select
|
||||
value={wallDeviceId}
|
||||
onChange={(e) => {
|
||||
setWallDeviceId(e.target.value);
|
||||
setWallTargetId("");
|
||||
}}
|
||||
>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{deviceLabel(d)}
|
||||
{d.ne_ip ? ` (${d.ne_ip})` : ""}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="pt-wall-page__field pt-wall-page__field--port">
|
||||
{t("portTraffic.wallPort")}
|
||||
<select value={wallTargetId} onChange={(e) => setWallTargetId(e.target.value)}>
|
||||
<option value="">{t("portTraffic.pickPort")}</option>
|
||||
{wallTargets.map((tgt) => (
|
||||
<option key={tgt.id} value={tgt.id}>
|
||||
{tgt.ifname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-primary pt-wall-page__collect"
|
||||
disabled={!wallDeviceId || collectMut.isPending}
|
||||
onClick={() => collectMut.mutate(wallDeviceId)}
|
||||
>
|
||||
{t("portTraffic.collectNow")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="pt-wall-page__fs-btn pt-wall-page__fs-btn--toolbar"
|
||||
onClick={() => void toggleFullscreen()}
|
||||
title={t("portTraffic.fullscreen")}
|
||||
aria-label={t("portTraffic.fullscreen")}
|
||||
>
|
||||
<FullscreenIcon />
|
||||
<span>{t("portTraffic.fullscreen")}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pt-wall-page__row pt-wall-page__row--secondary">
|
||||
<div className="pt-wall-page__cluster">
|
||||
<span className="pt-wall-page__cluster-label">{t("portTraffic.filterView")}</span>
|
||||
<div className="pt-wall-page__cluster-fields">
|
||||
<label className="pt-wall-page__field pt-wall-page__field--sm">
|
||||
{t("portTraffic.range")}
|
||||
<select value={rangeHours} onChange={(e) => setRangeHours(Number(e.target.value))}>
|
||||
<option value={1}>1h</option>
|
||||
<option value={6}>6h</option>
|
||||
<option value={24}>24h</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="pt-wall-page__field pt-wall-page__field--md">
|
||||
{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 className="pt-wall-page__field pt-wall-page__field--sm">
|
||||
{t("portTraffic.offsetHours")}
|
||||
<input
|
||||
type="number"
|
||||
min={1}
|
||||
max={24 * 90}
|
||||
value={customOffsetHours}
|
||||
onChange={(e) => setCustomOffsetHours(Number(e.target.value) || 24)}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label className="pt-wall-page__field pt-wall-page__field--md">
|
||||
{t("portTraffic.yMode")}
|
||||
<select value={yMode} onChange={(e) => setYMode(e.target.value as WallYMode)}>
|
||||
<option value="auto">{t("portTraffic.yModeAuto")}</option>
|
||||
<option value="current">{t("portTraffic.yModeCurrent")}</option>
|
||||
<option value="util">{t("portTraffic.yModeUtil")}</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-wall-page__cluster-sep" aria-hidden />
|
||||
|
||||
<div className="pt-wall-page__cluster pt-wall-page__cluster--map">
|
||||
<span className="pt-wall-page__cluster-label">{t("portTraffic.filterMap")}</span>
|
||||
<div className="pt-wall-page__cluster-fields">
|
||||
<label className="pt-wall-page__field pt-wall-page__field--task">
|
||||
{t("portTraffic.wallDevice")}
|
||||
<select
|
||||
value={mapBaselineDeviceId}
|
||||
onChange={(e) => {
|
||||
setMapBaselineDeviceId(e.target.value);
|
||||
setMapBaselineTargetId("");
|
||||
}}
|
||||
disabled={!wallTargetId}
|
||||
>
|
||||
<option value="">{t("portTraffic.mapBaselineNone")}</option>
|
||||
{devices.map((d) => (
|
||||
<option key={d.id} value={d.id}>
|
||||
{deviceLabel(d)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="pt-wall-page__field pt-wall-page__field--port">
|
||||
{t("portTraffic.wallPort")}
|
||||
<select
|
||||
value={mapBaselineTargetId}
|
||||
onChange={(e) => setMapBaselineTargetId(e.target.value)}
|
||||
disabled={!mapBaselineDeviceId}
|
||||
>
|
||||
<option value="">{t("portTraffic.pickPort")}</option>
|
||||
{mapBaselineOptions.map((tgt) => (
|
||||
<option key={tgt.id} value={tgt.id}>
|
||||
{tgt.ifname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{baseline === "week" ? (
|
||||
<p className="muted pt-wall-page__hint">{t("portTraffic.retentionHint")}</p>
|
||||
) : null}
|
||||
|
||||
<div
|
||||
ref={stageRef}
|
||||
className={`pt-wall-page__stage${fullscreen ? " is-fullscreen" : ""}`}
|
||||
>
|
||||
<PortTrafficWall
|
||||
target={selectedWallTarget}
|
||||
baselineTarget={
|
||||
compareQuery.data?.meta?.baseline_target ||
|
||||
(baseline !== "off" && wallBaselinePoints.length ? selectedWallTarget : null)
|
||||
}
|
||||
points={wallPoints}
|
||||
baselinePoints={wallBaselinePoints}
|
||||
yMode={yMode}
|
||||
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")
|
||||
: selectedWallTarget?.last_error
|
||||
? t("portTraffic.waitAfterError")
|
||||
: t("portTraffic.waitSamples")
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue