mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 02:00:45 +08:00
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:
parent
a95c6598bb
commit
efe8f58cd2
25 changed files with 2880 additions and 36 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
645
web/src/pages/network/PortTrafficPage.tsx
Normal file
645
web/src/pages/network/PortTrafficPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
157
web/src/pages/network/PortTrafficWall.tsx
Normal file
157
web/src/pages/network/PortTrafficWall.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue