Add ZTE-first port traffic monitoring with task wizard and ops wall.

Collect interface bit/s via CLI on a schedule, store samples in Postgres, and chart trends with uPlot.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-07-31 14:56:50 +08:00
parent a95c6598bb
commit efe8f58cd2
25 changed files with 2880 additions and 36 deletions

View file

@ -1,26 +1,13 @@
import { useI18n } from "../../i18n";
type Props = {
kind: "port-traffic";
kind?: string;
};
export function NetworkPlaceholderPage({ kind }: Props) {
const { t } = useI18n();
const titleKey = kind === "port-traffic" ? "network.placeholder.portTrafficTitle" : "network.placeholder.title";
const bodyKey = kind === "port-traffic" ? "network.placeholder.portTrafficBody" : "network.placeholder.body";
/** Generic placeholder for unfinished network sub-pages. */
export function NetworkPlaceholderPage(_props: Props) {
return (
<section className="panel network-placeholder">
<h2>{t(titleKey)}</h2>
<p className="muted">{t(bodyKey)}</p>
{kind === "port-traffic" ? (
<ul className="network-placeholder__list">
<li>{t("network.placeholder.portTrafficStep1")}</li>
<li>{t("network.placeholder.portTrafficStep2")}</li>
<li>{t("network.placeholder.portTrafficStep3")}</li>
<li>{t("network.placeholder.portTrafficStep4")}</li>
</ul>
) : null}
<h2>Coming soon</h2>
<p className="muted">This capability is not available yet.</p>
</section>
);
}

View file

@ -0,0 +1,645 @@
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createPortTrafficTask,
deletePortTrafficTask,
discoverPortTrafficPorts,
fetchCliTargets,
fetchPortTrafficDashboard,
fetchPortTrafficSamples,
fetchPortTrafficTargets,
fetchPortTrafficTasks,
pausePortTrafficTask,
startPortTrafficTask,
stopPortTrafficTask,
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import type { CliTargetItem, PortTrafficDiscoverPort, PortTrafficTargetIn } from "../../types";
import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
import { PortTrafficWall } from "./PortTrafficWall";
const POLL_MS = 5000;
const TARGET_PAGE_SIZE = 20;
type PortPick = PortTrafficTargetIn & { key: string };
function neKey(source: string, id: string) {
return `${source}:${id}`;
}
function portKey(source: string, id: string, ifname: string) {
return `${source}:${id}:${ifname}`;
}
function formatBw(bps: number) {
if (!bps) return "—";
if (bps >= 1e9) return `${bps / 1e9}G`;
if (bps >= 1e6) return `${bps / 1e6}M`;
return `${bps}`;
}
export function PortTrafficPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [view, setView] = useState<"list" | "wizard" | "wall">("list");
const [taskPage, setTaskPage] = useState(1);
const [wallTaskId, setWallTaskId] = useState("");
const [wallTargetId, setWallTargetId] = useState("");
const [rangeHours, setRangeHours] = useState(1);
// Wizard state
const [step, setStep] = useState(1);
const [title, setTitle] = useState("");
const [intervalSec, setIntervalSec] = useState(60);
const [retentionDays, setRetentionDays] = useState(7);
const [concurrency, setConcurrency] = useState(5);
const [startNow, setStartNow] = useState(true);
const [neKeyword, setNeKeyword] = useState("");
const [nePage, setNePage] = useState(1);
const [selectedNes, setSelectedNes] = useState<Record<string, CliTargetItem>>({});
const [portsByNe, setPortsByNe] = useState<
Record<string, { loading?: boolean; error?: string; ports: PortTrafficDiscoverPort[]; meta?: { ne_name: string; ne_ip: string; vendor: string } }>
>({});
const [pickedPorts, setPickedPorts] = useState<Record<string, PortPick>>({});
const dashQuery = useQuery({
queryKey: queryKeys.portTrafficDashboard,
queryFn: fetchPortTrafficDashboard,
staleTime: 2000,
refetchInterval: POLL_MS,
});
const tasksQuery = useQuery({
queryKey: queryKeys.portTrafficTasks(taskPage),
queryFn: () => fetchPortTrafficTasks({ page: taskPage, pageSize: 10 }),
staleTime: 2000,
refetchInterval: POLL_MS,
});
const wallTasksQuery = useQuery({
queryKey: queryKeys.portTrafficTasks(0),
queryFn: () => fetchPortTrafficTasks({ page: 1, pageSize: 100 }),
enabled: view === "wall",
staleTime: 5000,
});
const nesQuery = useQuery({
queryKey: queryKeys.cliTargets(neKeyword, nePage, TARGET_PAGE_SIZE),
queryFn: () =>
fetchCliTargets({ source: "all", keyword: neKeyword, page: nePage, pageSize: TARGET_PAGE_SIZE }),
enabled: view === "wizard" && step === 2,
staleTime: 5000,
});
const wallTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(wallTaskId),
queryFn: () => fetchPortTrafficTargets(wallTaskId),
enabled: view === "wall" && Boolean(wallTaskId),
staleTime: 3000,
});
const samplesQuery = useQuery({
queryKey: queryKeys.portTrafficSamples(wallTargetId, rangeHours),
queryFn: () => {
const to = new Date();
const from = new Date(to.getTime() - rangeHours * 3600 * 1000);
return fetchPortTrafficSamples({
targetId: wallTargetId,
from: from.toISOString(),
to: to.toISOString(),
});
},
enabled: view === "wall" && Boolean(wallTargetId),
staleTime: 2000,
refetchInterval: POLL_MS,
});
const invalidateAll = () => {
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficTasksAll });
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficDashboard });
};
const createMut = useMutation({
mutationFn: createPortTrafficTask,
onSuccess: () => {
showOk(t("portTraffic.created"));
invalidateAll();
resetWizard();
setView("list");
},
onError: (e: Error) => showError(e.message || t("portTraffic.createFailed")),
});
const startMut = useMutation({
mutationFn: startPortTrafficTask,
onSuccess: () => {
showOk(t("portTraffic.started"));
invalidateAll();
},
onError: (e: Error) => showError(e.message),
});
const pauseMut = useMutation({
mutationFn: pausePortTrafficTask,
onSuccess: () => {
showOk(t("portTraffic.paused"));
invalidateAll();
},
onError: (e: Error) => showError(e.message),
});
const stopMut = useMutation({
mutationFn: stopPortTrafficTask,
onSuccess: () => {
showOk(t("portTraffic.stopped"));
invalidateAll();
},
onError: (e: Error) => showError(e.message),
});
const deleteMut = useMutation({
mutationFn: deletePortTrafficTask,
onSuccess: () => {
showOk(t("portTraffic.deleted"));
invalidateAll();
},
onError: (e: Error) => showError(e.message),
});
const resetWizard = () => {
setStep(1);
setTitle("");
setIntervalSec(60);
setRetentionDays(7);
setConcurrency(5);
setStartNow(true);
setSelectedNes({});
setPortsByNe({});
setPickedPorts({});
setNeKeyword("");
setNePage(1);
};
const openWizard = () => {
resetWizard();
setView("wizard");
};
const openWall = (taskId: string) => {
setWallTaskId(taskId);
setWallTargetId("");
setView("wall");
};
const toggleNe = (row: CliTargetItem) => {
const k = neKey(row.source, row.id);
setSelectedNes((prev) => {
const next = { ...prev };
if (next[k]) delete next[k];
else next[k] = row;
return next;
});
};
const discoverNe = async (row: CliTargetItem) => {
const k = neKey(row.source, row.id);
setPortsByNe((prev) => ({ ...prev, [k]: { loading: true, ports: prev[k]?.ports || [] } }));
try {
const source = row.source === "ume" ? "ume" : "managed";
const res = await discoverPortTrafficPorts({ source, id: row.id });
setPortsByNe((prev) => ({
...prev,
[k]: {
ports: res.ports,
meta: { ne_name: res.ne_name || row.name, ne_ip: res.ne_ip || row.ip_address, vendor: res.vendor || row.vendor || "" },
},
}));
} catch (e) {
const msg = e instanceof Error ? e.message : "discover_failed";
setPortsByNe((prev) => ({ ...prev, [k]: { ports: [], error: msg } }));
showError(msg);
}
};
const togglePort = (ne: CliTargetItem, port: PortTrafficDiscoverPort, meta?: { ne_name: string; ne_ip: string; vendor: string }) => {
const source = (ne.source === "ume" ? "ume" : "managed") as "managed" | "ume";
const k = portKey(source, ne.id, port.ifname);
setPickedPorts((prev) => {
const next = { ...prev };
if (next[k]) {
delete next[k];
return next;
}
next[k] = {
key: k,
source,
target_id: ne.id,
ne_name: meta?.ne_name || ne.name,
ne_ip: meta?.ne_ip || ne.ip_address,
vendor: meta?.vendor || ne.vendor || "",
ifname: port.ifname,
if_description: port.description,
bw_bps: port.bw_bps,
};
return next;
});
};
const selectedNeList = useMemo(() => Object.values(selectedNes), [selectedNes]);
const pickedList = useMemo(() => Object.values(pickedPorts), [pickedPorts]);
const wallTargets = wallTargetsQuery.data?.items || [];
const selectedWallTarget = wallTargets.find((x) => x.id === wallTargetId) || null;
useEffect(() => {
if (view !== "wall" || wallTargetId || !wallTargets.length) return;
setWallTargetId(wallTargets[0].id);
}, [view, wallTargetId, wallTargets]);
const submitWizard = () => {
if (!title.trim()) {
showError(t("portTraffic.titleRequired"));
return;
}
if (!pickedList.length) {
showError(t("portTraffic.portsRequired"));
return;
}
createMut.mutate({
title: title.trim(),
interval_sec: intervalSec,
retention_days: retentionDays,
concurrency,
start_now: startNow,
targets: pickedList.map(({ key: _k, ...rest }) => rest),
});
};
const dash = dashQuery.data;
const tasks = tasksQuery.data?.items || [];
const taskPages = pageCount(tasksQuery.data?.total || 0, 10);
return (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("portTraffic.title")}</h2>
<div className="btn-row">
{view !== "list" ? (
<button type="button" onClick={() => setView("list")}>
{t("portTraffic.backList")}
</button>
) : (
<button type="button" className="btn-primary" onClick={openWizard}>
{t("portTraffic.create")}
</button>
)}
</div>
</div>
{view === "list" ? (
<>
<div className="stat-grid" style={{ marginBottom: 16 }}>
<div className="stat-card">
<div className="muted">{t("portTraffic.kpi.tasks")}</div>
<div>{dash?.task_count ?? "—"}</div>
</div>
<div className="stat-card">
<div className="muted">{t("portTraffic.kpi.running")}</div>
<div>{dash?.running_task_count ?? "—"}</div>
</div>
<div className="stat-card">
<div className="muted">{t("portTraffic.kpi.ports")}</div>
<div>{dash?.active_target_count ?? "—"}</div>
</div>
<div className="stat-card">
<div className="muted">{t("portTraffic.kpi.samples24h")}</div>
<div>{dash?.sample_count_24h ?? "—"}</div>
</div>
</div>
<table className="data-table">
<thead>
<tr>
<th>{t("portTraffic.col.title")}</th>
<th>{t("portTraffic.col.status")}</th>
<th>{t("portTraffic.col.ports")}</th>
<th>{t("portTraffic.col.interval")}</th>
<th>{t("portTraffic.col.last")}</th>
<th>{t("portTraffic.col.actions")}</th>
</tr>
</thead>
<tbody>
{!tasks.length ? (
<tr>
<td colSpan={6} className="muted">
{t("portTraffic.empty")}
</td>
</tr>
) : (
tasks.map((row) => (
<tr key={row.id}>
<td>{row.title}</td>
<td>
{row.status}
{row.collect_running ? " · collecting" : ""}
</td>
<td>
{row.active_target_count}/{row.target_count}
</td>
<td>{row.interval_sec}s</td>
<td>{formatSystemTime(row.last_collect_ended_at) || "—"}</td>
<td>
<div className="btn-row">
<button type="button" onClick={() => openWall(row.id)}>
{t("portTraffic.wall")}
</button>
{row.status !== "running" ? (
<button type="button" disabled={startMut.isPending} onClick={() => startMut.mutate(row.id)}>
{t("portTraffic.start")}
</button>
) : (
<button type="button" disabled={pauseMut.isPending} onClick={() => pauseMut.mutate(row.id)}>
{t("portTraffic.pause")}
</button>
)}
{row.status !== "stopped" ? (
<button type="button" disabled={stopMut.isPending} onClick={() => stopMut.mutate(row.id)}>
{t("portTraffic.stop")}
</button>
) : null}
<button
type="button"
disabled={deleteMut.isPending}
onClick={() => {
if (window.confirm(t("portTraffic.confirmDelete"))) deleteMut.mutate(row.id);
}}
>
{t("portTraffic.delete")}
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
<div className="pager">
<button type="button" className="pager__btn" disabled={taskPage <= 1} onClick={() => setTaskPage((p) => p - 1)}>
‹
</button>
<span className="muted">
{taskPage}/{Math.max(1, taskPages)}
</span>
<button
type="button"
className="pager__btn"
disabled={taskPage >= taskPages}
onClick={() => setTaskPage((p) => p + 1)}
>
›
</button>
</div>
</>
) : null}
{view === "wizard" ? (
<div className="pt-wizard">
<div className="pt-wizard__steps">
{[1, 2, 3, 4].map((n) => (
<button
key={n}
type="button"
className={`pt-wizard__step${step === n ? " is-active" : ""}`}
onClick={() => setStep(n)}
>
{n}. {t(`portTraffic.step${n}`)}
</button>
))}
</div>
{step === 1 ? (
<div className="config-sync-policy-row">
<label className="config-sync-policy-field">
{t("portTraffic.fieldTitle")}
<input value={title} onChange={(e) => setTitle(e.target.value)} placeholder={t("portTraffic.titlePh")} />
</label>
<label className="config-sync-policy-field">
{t("portTraffic.interval")}
<input
type="number"
min={15}
max={3600}
value={intervalSec}
onChange={(e) => setIntervalSec(Number(e.target.value) || 60)}
/>
</label>
<label className="config-sync-policy-field">
{t("portTraffic.retention")}
<input
type="number"
min={1}
max={90}
value={retentionDays}
onChange={(e) => setRetentionDays(Number(e.target.value) || 7)}
/>
</label>
<label className="config-sync-policy-field">
{t("portTraffic.concurrency")}
<input
type="number"
min={1}
max={20}
value={concurrency}
onChange={(e) => setConcurrency(Number(e.target.value) || 5)}
/>
</label>
<label className="config-sync-policy-check">
<input type="checkbox" checked={startNow} onChange={(e) => setStartNow(e.target.checked)} />
{t("portTraffic.startNow")}
</label>
</div>
) : null}
{step === 2 ? (
<>
<div className="filter-inline">
<input
value={neKeyword}
onChange={(e) => {
setNeKeyword(e.target.value);
setNePage(1);
}}
placeholder={t("portTraffic.neKeywordPh")}
/>
<span className="muted">{t("portTraffic.selectedNe", { count: selectedNeList.length })}</span>
</div>
<table className="data-table">
<thead>
<tr>
<th />
<th>{t("portTraffic.col.source")}</th>
<th>{t("portTraffic.col.name")}</th>
<th>IP</th>
<th>{t("portTraffic.col.vendor")}</th>
</tr>
</thead>
<tbody>
{(nesQuery.data?.items || []).map((row) => {
const k = neKey(row.source, row.id);
return (
<tr key={k}>
<td>
<input type="checkbox" checked={Boolean(selectedNes[k])} onChange={() => toggleNe(row)} />
</td>
<td>{row.source}</td>
<td>{row.name}</td>
<td>{row.ip_address}</td>
<td>{row.vendor || "—"}</td>
</tr>
);
})}
</tbody>
</table>
</>
) : null}
{step === 3 ? (
<div className="pt-wizard__ports">
{!selectedNeList.length ? <p>{t("portTraffic.pickNeFirst")}</p> : null}
{selectedNeList.map((ne) => {
const k = neKey(ne.source, ne.id);
const bucket = portsByNe[k];
return (
<div key={k} className="pt-wizard__ne-block">
<div className="btn-row" style={{ marginBottom: 8 }}>
<strong>
{ne.name} ({ne.ip_address})
</strong>
<button type="button" disabled={bucket?.loading} onClick={() => void discoverNe(ne)}>
{bucket?.loading ? "…" : t("portTraffic.discover")}
</button>
{bucket?.error ? <span className="muted">{bucket.error}</span> : null}
</div>
{bucket?.ports?.length ? (
<table className="data-table">
<thead>
<tr>
<th />
<th>Interface</th>
<th>BW</th>
<th>Admin/Phy/Prot</th>
<th>Description</th>
</tr>
</thead>
<tbody>
{bucket.ports.map((p) => {
const pk = portKey(ne.source === "ume" ? "ume" : "managed", ne.id, p.ifname);
return (
<tr key={pk}>
<td>
<input
type="checkbox"
checked={Boolean(pickedPorts[pk])}
onChange={() => togglePort(ne, p, bucket.meta)}
/>
</td>
<td>{p.ifname}</td>
<td>{p.bw_raw || formatBw(p.bw_bps)}</td>
<td>
{p.admin}/{p.phy}/{p.prot}
</td>
<td>{p.description || "—"}</td>
</tr>
);
})}
</tbody>
</table>
) : null}
</div>
);
})}
<p>{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</p>
</div>
) : null}
{step === 4 ? (
<div className="pt-wizard__confirm">
<p>
<strong>{title || "—"}</strong> · {intervalSec}s · {retentionDays}d · ×{concurrency}
</p>
<p>{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</p>
<ul className="pt-wizard__confirm-list">
{pickedList.slice(0, 40).map((p) => (
<li key={p.key}>
{p.ne_name} / {p.ifname} ({formatBw(p.bw_bps || 0)})
</li>
))}
{pickedList.length > 40 ? <li>… +{pickedList.length - 40}</li> : null}
</ul>
<button type="button" className="btn-primary" disabled={createMut.isPending} onClick={submitWizard}>
{t("portTraffic.confirmCreate")}
</button>
</div>
) : null}
<div className="btn-row" style={{ marginTop: 12 }}>
<button type="button" disabled={step <= 1} onClick={() => setStep((s) => s - 1)}>
{t("portTraffic.prev")}
</button>
<button type="button" disabled={step >= 4} onClick={() => setStep((s) => s + 1)}>
{t("portTraffic.next")}
</button>
</div>
</div>
) : null}
{view === "wall" ? (
<div className="pt-wall-page">
<div className="filter-inline pt-wall-page__filters">
<label>
{t("portTraffic.wallTask")}
<select
value={wallTaskId}
onChange={(e) => {
setWallTaskId(e.target.value);
setWallTargetId("");
}}
>
{(wallTasksQuery.data?.items || tasksQuery.data?.items || []).map((task) => (
<option key={task.id} value={task.id}>
{task.title}
</option>
))}
</select>
</label>
<label>
{t("portTraffic.wallPort")}
<select value={wallTargetId} onChange={(e) => setWallTargetId(e.target.value)}>
<option value="">{t("portTraffic.pickPort")}</option>
{wallTargets.map((tg) => (
<option key={tg.id} value={tg.id}>
{tg.ne_name} / {tg.ifname}
</option>
))}
</select>
</label>
<label>
{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>
</div>
<PortTrafficWall
target={selectedWallTarget}
points={samplesQuery.data?.points || []}
rangeLabel={`${rangeHours}h`}
/>
</div>
) : null}
</section>
);
}

View file

@ -0,0 +1,157 @@
import { useEffect, useMemo, useRef } from "react";
import uPlot from "uplot";
import "uplot/dist/uPlot.min.css";
import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../types";
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)}`;
}
function formatBwLabel(bps: number): string {
if (!bps) return "—";
return `${formatBps(bps)}bit/s`;
}
type Props = {
target: PortTrafficTarget | null;
points: PortTrafficSamplePoint[];
rangeLabel: string;
};
export function PortTrafficWall({ target, points, rangeLabel }: Props) {
const rootRef = useRef<HTMLDivElement | null>(null);
const plotRef = useRef<uPlot | null>(null);
const latest = points.length ? points[points.length - 1] : null;
const kpi = useMemo(() => {
const bw = latest?.bw_bps || target?.bw_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,
};
}, [latest, target]);
useEffect(() => {
const el = rootRef.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 opts: uPlot.Options = {
width: Math.max(320, el.clientWidth || 800),
height: Math.max(220, Math.min(420, Math.floor((el.clientWidth || 800) * 0.32))),
series: [
{},
{
label: "In",
stroke: "#2dd4bf",
width: 2,
points: { show: false },
},
{
label: "Out",
stroke: "#f59e0b",
width: 2,
points: { show: false },
},
],
axes: [
{
stroke: "#94a3b8",
grid: { stroke: "rgba(148,163,184,0.18)", width: 1 },
ticks: { stroke: "rgba(148,163,184,0.35)" },
},
{
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)),
size: 56,
},
],
scales: {
x: { time: true },
},
legend: { show: true },
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);
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))),
});
};
window.addEventListener("resize", onResize);
return () => {
window.removeEventListener("resize", onResize);
plotRef.current?.destroy();
plotRef.current = null;
};
}, [points]);
return (
<div className="pt-wall">
<div className="pt-wall__head">
<div className="pt-wall__title">
<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>
<div className="pt-wall__kpis">
<div className="pt-wall__kpi">
<div className="pt-wall__kpi-label">Bandwidth</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</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</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 In / Out</div>
<div className="pt-wall__kpi-value">
{kpi.inUtil.toFixed(1)}%
<span className="pt-wall__kpi-sep">/</span>
{kpi.outUtil.toFixed(1)}%
</div>
</div>
</div>
<div className="pt-wall__chart" ref={rootRef}>
{!points.length ? <div className="pt-wall__empty">No samples in range</div> : null}
</div>
</div>
);
}