Add named multi-panel traffic boards with dedicated wall tabs.

Persist board/panel layouts server-side, open each board outside the network shell so workbench reloads do not refresh walls, and fix mapped-baseline chart overlay plus fullscreen fit.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-08-01 17:03:11 +08:00
parent 214bc56774
commit 00d3e95e2d
22 changed files with 1923 additions and 345 deletions

View file

@ -17,6 +17,7 @@ export function WorkbenchPage() {
<h2 className="wb-section__title">{t(`workbench.${section}`)}</h2>
<div className="wb-grid">
{modulesInSection(section)
.filter((mod) => !mod.workbenchHidden)
.filter((mod) => !mod.adminOnly || isAdmin)
.map((mod) => (
<button

View file

@ -0,0 +1,145 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createPortTrafficBoard,
deletePortTrafficBoard,
fetchPortTrafficBoards,
} from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { openPortTrafficBoardWindow } from "../../utils/portTrafficBoardWindow";
import { formatSystemTime } from "../../utils/time";
export function PortTrafficBoardListPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const boardsQuery = useQuery({
queryKey: queryKeys.portTrafficBoards,
queryFn: fetchPortTrafficBoards,
staleTime: 5000,
});
const createMut = useMutation({
mutationFn: () =>
createPortTrafficBoard({
name: t("portTraffic.boardDefaultName"),
cols: 2,
panels: [],
}),
onSuccess: (created) => {
showOk(t("portTraffic.boardCreated"));
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoards });
openPortTrafficBoardWindow(created.id);
},
onError: (e: Error) => showError(e.message),
});
const deleteMut = useMutation({
mutationFn: (boardId: string) => deletePortTrafficBoard(boardId),
onSuccess: () => {
showOk(t("portTraffic.boardDeleted"));
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoards });
},
onError: (e: Error) => showError(e.message),
});
const boards = boardsQuery.data?.items || [];
return (
<section className="panel">
<div className="panel__toolbar">
<h2>{t("portTraffic.wallTitle")}</h2>
<div className="btn-row">
<button
type="button"
className="btn-primary"
disabled={createMut.isPending}
onClick={() => createMut.mutate()}
>
{t("portTraffic.boardCreate")}
</button>
</div>
</div>
<p className="muted" style={{ marginTop: 0 }}>
{t("portTraffic.boardListHint")}
</p>
<div className="pt-list">
{boardsQuery.isLoading ? (
<p className="muted">…</p>
) : boardsQuery.isError ? (
<p className="error">{(boardsQuery.error as Error)?.message || t("common.opFailed")}</p>
) : !boards.length ? (
<div className="pt-list-empty">
<p>{t("portTraffic.boardEmpty")}</p>
<button
type="button"
className="btn-primary"
disabled={createMut.isPending}
onClick={() => createMut.mutate()}
>
{t("portTraffic.boardCreate")}
</button>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("portTraffic.boardName")}</th>
<th>{t("portTraffic.boardPanelCount")}</th>
<th>{t("portTraffic.boardCols")}</th>
<th>{t("portTraffic.boardUpdated")}</th>
<th>{t("portTraffic.col.actions")}</th>
</tr>
</thead>
<tbody>
{boards.map((b) => (
<tr key={b.id}>
<td>
<button
type="button"
className="link-btn pt-list-task-name"
onClick={() => openPortTrafficBoardWindow(b.id)}
>
{b.name || t("portTraffic.boardDefaultName")}
</button>
</td>
<td>{b.panel_count}</td>
<td>{b.cols}</td>
<td>{b.updated_at ? formatSystemTime(b.updated_at) : "—"}</td>
<td>
<div className="btn-row table-actions">
<button
type="button"
className="btn-primary"
onClick={() => openPortTrafficBoardWindow(b.id)}
>
{t("portTraffic.boardOpen")}
</button>
<button
type="button"
className="btn--danger"
disabled={deleteMut.isPending}
onClick={() => {
if (window.confirm(t("portTraffic.boardConfirmDelete"))) {
deleteMut.mutate(b.id);
}
}}
>
{t("portTraffic.delete")}
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</section>
);
}

View file

@ -0,0 +1,132 @@
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { fetchPortTrafficCompare } from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import type { PortTrafficBoardPanel } from "../../types";
import { PortTrafficWall, type WallYMode } from "./PortTrafficWall";
const POLL_MS = 5000;
const EMPTY: never[] = [];
type Props = {
boardId: string;
panel: PortTrafficBoardPanel;
editing?: boolean;
dense?: boolean;
onEdit?: () => void;
onRemove?: () => void;
onMove?: (dir: -1 | 1) => void;
};
export function PortTrafficBoardPanelCell({
boardId,
panel,
editing,
dense,
onEdit,
onRemove,
onMove,
}: Props) {
const { t } = useI18n();
const baseline = panel.baseline || "off";
const compareQuery = useQuery({
queryKey: [
...queryKeys.portTrafficCompare(
panel.target_id,
panel.range_hours,
baseline,
baseline === "custom" ? panel.offset_hours : 0,
panel.baseline_target_id || "",
),
boardId,
panel.id,
],
queryFn: () =>
fetchPortTrafficCompare({
targetId: panel.target_id,
rangeHours: panel.range_hours,
baseline,
offsetHours: baseline === "custom" ? panel.offset_hours : undefined,
baselineTargetId: panel.baseline_target_id || undefined,
}),
enabled: Boolean(panel.target_id) && !panel.stale,
staleTime: 1000,
placeholderData: keepPreviousData,
refetchInterval: POLL_MS,
});
const points = compareQuery.data?.current ?? EMPTY;
const baselinePoints =
baseline === "off" && !panel.baseline_target_id
? EMPTY
: (compareQuery.data?.baseline ?? EMPTY);
const target = panel.target || compareQuery.data?.meta?.current_target || null;
const baselineTarget =
compareQuery.data?.meta?.baseline_target || panel.baseline_target || null;
const title =
panel.title ||
(target ? `${target.ne_name || target.ne_ip || "—"} · ${target.ifname}` : t("portTraffic.pickPort"));
const rangeLabel = `${panel.range_hours}h${
baseline === "off"
? ""
: baseline === "day"
? ` · ${t("portTraffic.compareDay")}`
: baseline === "week"
? ` · ${t("portTraffic.compareWeek")}`
: baseline === "shift"
? ` · ${t("portTraffic.compareShift")}`
: ` · ${t("portTraffic.compareCustom")}`
}${panel.baseline_target_id ? ` · ${t("portTraffic.mapBaselinePort")}` : ""}`;
return (
<div
className={`pt-board-panel${panel.stale ? " is-stale" : ""}`}
style={{ gridColumn: `span ${Math.max(1, panel.col_span || 1)}` }}
>
{editing ? (
<div className="pt-board-panel__editbar">
<span className="pt-board-panel__edit-title" title={title}>
{title}
</span>
<div className="btn-row">
<button type="button" onClick={() => onMove?.(-1)}>
↑
</button>
<button type="button" onClick={() => onMove?.(1)}>
↓
</button>
<button type="button" onClick={() => onEdit?.()}>
{t("portTraffic.edit")}
</button>
<button type="button" className="btn--danger" onClick={() => onRemove?.()}>
{t("portTraffic.delete")}
</button>
</div>
</div>
) : null}
{panel.stale ? (
<div className="pt-board-panel__stale">
<p>{t("portTraffic.boardPanelStale")}</p>
{editing ? (
<button type="button" className="btn-primary" onClick={() => onEdit?.()}>
{t("portTraffic.boardRetarget")}
</button>
) : null}
</div>
) : (
<PortTrafficWall
target={target}
baselineTarget={baselineTarget}
points={points}
baselinePoints={baselinePoints}
yMode={(panel.y_mode as WallYMode) || "auto"}
rangeLabel={rangeLabel}
loading={compareQuery.isLoading || compareQuery.isFetching}
hint={t("portTraffic.waitSamples")}
dense={dense}
/>
)}
</div>
);
}

View file

@ -256,8 +256,8 @@ export function PortTrafficPage() {
setView("wizard");
};
const openWall = (deviceId: string) => {
navigate(`/network/tasks/port-traffic/wall?device_id=${encodeURIComponent(deviceId)}`);
const openWallList = () => {
navigate("/network/tasks/port-traffic/wall");
};
const openEdit = (row: PortTrafficDevice) => {
@ -523,7 +523,7 @@ export function PortTrafficPage() {
<button
type="button"
className="btn-primary"
onClick={() => openWall(row.id)}
onClick={() => openWallList()}
>
{t("portTraffic.wall")}
</button>

View file

@ -12,6 +12,7 @@ function formatBps(n: number): string {
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`;
if (v > 0 && v < 1) return n.toFixed(2);
return `${Math.round(n)}`;
}
@ -75,6 +76,8 @@ function toAlignedSeries(
baseBwFallback: number,
): [number[], number[], number[], number[], number[]] {
type Row = { x: number; inC?: number; outC?: number; inB?: number; outB?: number };
// Shared X = union of both timelines; each series only has values at its own samples.
// Gaps stay NaN; series use spanGaps so each line connects on its own cadence.
const map = new Map<number, Row>();
for (const p of current) {
const d = parseApiTime(p.ts);
@ -108,14 +111,13 @@ function toAlignedSeries(
}
function chartSize(el: HTMLElement): { width: number; height: number } {
const width = Math.max(320, el.clientWidth || el.parentElement?.clientWidth || 800);
const width = Math.max(120, el.clientWidth || el.parentElement?.clientWidth || 800);
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 };
// Follow container height in board/fullscreen panels (may be well under 180px).
if (parentH > 0) {
return { width, height: Math.max(64, parentH) };
}
return { width, height: Math.max(220, Math.min(440, Math.floor(width * 0.34))) };
}
const EMPTY_POINTS: PortTrafficSamplePoint[] = [];
@ -129,6 +131,8 @@ type Props = {
yMode?: WallYMode;
loading?: boolean;
hint?: string;
/** Board fullscreen: denser chrome, no per-panel legend. */
dense?: boolean;
};
export function PortTrafficWall({
@ -140,6 +144,7 @@ export function PortTrafficWall({
yMode = "auto",
loading,
hint,
dense = false,
}: Props) {
const { t } = useI18n();
const mountRef = useRef<HTMLDivElement | null>(null);
@ -202,7 +207,7 @@ export function PortTrafficWall({
width: 2,
fill: "rgba(45, 212, 191, 0.08)",
points: { show: false },
spanGaps: false,
spanGaps: true,
},
{
label: t("portTraffic.seriesCurrentOut"),
@ -210,7 +215,7 @@ export function PortTrafficWall({
width: 2,
fill: "rgba(251, 191, 36, 0.07)",
points: { show: false },
spanGaps: false,
spanGaps: true,
},
{
label: t("portTraffic.seriesBaselineIn"),
@ -218,7 +223,7 @@ export function PortTrafficWall({
width: 1.5,
dash: [6, 4],
points: { show: false },
spanGaps: false,
spanGaps: true,
show: false,
},
{
@ -227,7 +232,7 @@ export function PortTrafficWall({
width: 1.5,
dash: [6, 4],
points: { show: false },
spanGaps: false,
spanGaps: true,
show: false,
},
],
@ -257,13 +262,18 @@ export function PortTrafficWall({
y: {
auto: true,
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));
const mode = yModeRef.current;
// "current": scale to live series only. "auto"/util: include baseline too,
// otherwise a near-zero current + busy mapped baseline clips baseline off-chart.
const seriesIdxs =
mode === "current" ? [1, 2] : mode === "util" ? [1, 2, 3, 4] : [1, 2, 3, 4];
const vals: number[] = [];
for (const i of seriesIdxs) {
for (const v of u.data[i] || []) {
if (Number.isFinite(v)) vals.push(v as number);
}
}
if (vals.length) return yPadRange(0, finiteMax(vals));
return yPadRange(min, max);
},
},
@ -355,6 +365,7 @@ export function PortTrafficWall({
plotRef.current.setSize(chartSize(mountRef.current));
};
window.addEventListener("resize", onResize);
document.addEventListener("fullscreenchange", onResize);
const ro =
typeof ResizeObserver !== "undefined"
? new ResizeObserver(() => onResize())
@ -362,8 +373,15 @@ export function PortTrafficWall({
ro?.observe(el);
const wrap = el.parentElement;
if (wrap) ro?.observe(wrap);
// Observe panel / stage ancestors so fullscreen grid row shrinks resize plots.
let node: HTMLElement | null = wrap?.parentElement ?? null;
for (let i = 0; i < 4 && node; i += 1) {
ro?.observe(node);
node = node.parentElement;
}
return () => {
window.removeEventListener("resize", onResize);
document.removeEventListener("fullscreenchange", onResize);
ro?.disconnect();
plotRef.current?.over.removeEventListener("mouseleave", onLeave);
plotRef.current?.destroy();
@ -415,12 +433,18 @@ export function PortTrafficWall({
const baseIpLabel = baselineTarget?.ne_ip || "";
return (
<div className="pt-wall">
<div className={`pt-wall${dense ? " pt-wall--dense" : ""}`}>
<div className="pt-wall__head">
<div className="pt-wall__title">
<div className="pt-wall__ne">
{neLabel}
{ipLabel ? ` · ${ipLabel}` : ""}
{dense && showBaselineMeta ? (
<span className="pt-wall__ne-base" title={`${baseNeLabel} · ${baseIfLabel}`}>
{" · "}
{t("portTraffic.baseline")}: {baseNeLabel} · {baseIfLabel}
</span>
) : null}
</div>
<div className="pt-wall__if" title={ifLabel}>
{ifLabel}
@ -428,7 +452,7 @@ export function PortTrafficWall({
</div>
<div className="pt-wall__range">
{rangeLabel}
{kpi.ts ? ` · ${t("portTraffic.latestAt")} ${formatSystemTime(kpi.ts)}` : ""}
{kpi.ts ? ` · ${formatSystemTime(kpi.ts)}` : ""}
</div>
</div>
<div className="pt-wall__kpis">
@ -468,43 +492,45 @@ export function PortTrafficWall({
</div>
) : null}
</div>
<div className="pt-wall__foot">
<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>
{!dense ? (
<div className="pt-wall__foot">
<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 ? (
<>
<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>
</>
<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>
{baseIpLabel ? <span className="pt-wall__foot-ip">{baseIpLabel}</span> : null}
</div>
<div className="pt-wall__foot-if" title={baseIfLabel}>
<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>
{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>
{baseIpLabel ? <span className="pt-wall__foot-ip">{baseIpLabel}</span> : null}
</div>
<div className="pt-wall__foot-if" title={baseIfLabel}>
<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>
) : null}
</div>
);
}

View file

@ -1,27 +1,23 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient, keepPreviousData } from "@tanstack/react-query";
import { Link, useSearchParams } from "react-router-dom";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useParams } from "react-router-dom";
import {
collectPortTrafficNow,
fetchPortTrafficCompare,
deletePortTrafficBoard,
fetchPortTrafficBoard,
fetchPortTrafficDevices,
fetchPortTrafficTargets,
putPortTrafficBoardPanels,
updatePortTrafficBoard,
} 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[] = [];
import type { PortTrafficBoardPanel, PortTrafficBoardPanelIn } from "../../types";
import { PortTrafficBoardPanelCell } from "./PortTrafficBoardPanelCell";
import type { WallYMode } from "./PortTrafficWall";
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">
@ -33,138 +29,188 @@ function FullscreenIcon() {
);
}
function newPanelId() {
return `p_${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`;
}
function panelToIn(p: PortTrafficBoardPanel): PortTrafficBoardPanelIn {
return {
id: p.id,
title: p.title || "",
target_id: p.target_id,
range_hours: p.range_hours,
baseline: p.baseline || "off",
offset_hours: p.offset_hours || 0,
baseline_target_id: p.baseline_target_id || "",
y_mode: p.y_mode || "auto",
ord: p.ord,
col_span: p.col_span || 1,
row_span: p.row_span || 1,
};
}
/** Dedicated board wall tab at `/port-traffic/wall/:boardId` (outside Network shell). */
export function PortTrafficWallPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [searchParams, setSearchParams] = useSearchParams();
const deepLinkAppliedRef = useRef(false);
const { boardId: boardIdParam = "" } = useParams<{ boardId: string }>();
const boardId = decodeURIComponent(boardIdParam).trim();
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 [editing, setEditing] = useState(false);
const [dirty, setDirty] = useState(false);
const [fullscreen, setFullscreen] = useState(false);
const [draftPanels, setDraftPanels] = useState<PortTrafficBoardPanel[]>([]);
const [draftName, setDraftName] = useState("");
const [draftCols, setDraftCols] = useState(2);
const [editPanelId, setEditPanelId] = useState("");
const [editPickDeviceId, setEditPickDeviceId] = useState("");
const [editMapBaselineDeviceId, setEditMapBaselineDeviceId] = useState("");
const [addDeviceId, setAddDeviceId] = useState("");
const [addTargetId, setAddTargetId] = useState("");
const [deleted, setDeleted] = useState(false);
const boardQuery = useQuery({
queryKey: queryKeys.portTrafficBoard(boardId),
queryFn: () => fetchPortTrafficBoard(boardId),
enabled: Boolean(boardId) && !deleted,
staleTime: 2000,
});
const devicesQuery = useQuery({
queryKey: queryKeys.portTrafficDevices(0),
queryFn: () => fetchPortTrafficDevices({ page: 1, pageSize: 100 }),
enabled: editing,
staleTime: 5000,
refetchInterval: POLL_MS,
});
const wallTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(wallDeviceId),
queryFn: () => fetchPortTrafficTargets(wallDeviceId),
enabled: Boolean(wallDeviceId),
const addTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(addDeviceId),
queryFn: () => fetchPortTrafficTargets(addDeviceId),
enabled: editing && Boolean(addDeviceId),
staleTime: 2000,
refetchInterval: POLL_MS,
});
const editPanel = draftPanels.find((p) => p.id === editPanelId) || null;
const editTargetsDeviceId = editPickDeviceId || editPanel?.target?.device_id || "";
const editTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(editTargetsDeviceId),
queryFn: () => fetchPortTrafficTargets(editTargetsDeviceId),
enabled: editing && Boolean(editPanelId) && Boolean(editTargetsDeviceId),
staleTime: 2000,
});
const mapBaselineTargetsQuery = useQuery({
queryKey: queryKeys.portTrafficTargets(mapBaselineDeviceId),
queryFn: () => fetchPortTrafficTargets(mapBaselineDeviceId),
enabled: Boolean(mapBaselineDeviceId),
queryKey: queryKeys.portTrafficTargets(editMapBaselineDeviceId),
queryFn: () => fetchPortTrafficTargets(editMapBaselineDeviceId),
enabled: editing && Boolean(editPanelId) && Boolean(editMapBaselineDeviceId),
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,
(x) =>
(x.status === "active" || x.id === editPanel?.baseline_target_id) &&
x.id !== editPanel?.target_id,
),
[mapBaselineTargetsQuery.data?.items, wallTargetId],
[mapBaselineTargetsQuery.data?.items, editPanel?.baseline_target_id, editPanel?.target_id],
);
const wallPoints = compareQuery.data?.current ?? EMPTY_COMPARE_POINTS;
const wallBaselinePoints =
baseline === "off" && !mapBaselineTargetId
? EMPTY_COMPARE_POINTS
: (compareQuery.data?.baseline ?? EMPTY_COMPARE_POINTS);
const closePanelSettings = () => {
setEditPanelId("");
setEditPickDeviceId("");
setEditMapBaselineDeviceId("");
};
const openPanelSettings = (panelId: string) => {
const p = draftPanels.find((x) => x.id === panelId);
setEditPanelId(panelId);
setEditPickDeviceId(p?.target?.device_id || "");
setEditMapBaselineDeviceId(p?.baseline_target?.device_id || "");
};
const board = boardQuery.data || null;
const panels = editing ? draftPanels : board?.panels || [];
const cols = editing ? draftCols : board?.cols || 2;
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]);
if (!board || editing) return;
setDraftPanels(board.panels || []);
setDraftName(board.name || "");
setDraftCols(board.cols || 2);
}, [board, editing]);
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 (!board?.name) return;
try {
document.title = `${board.name} · NetX`;
} catch {
/* ignore */
}
if (
mapBaselineDeviceId &&
mapBaselineTargetsQuery.isFetched &&
!mapBaselineOptions.some((x) => x.id === mapBaselineTargetId)
) {
setMapBaselineTargetId("");
}, [board?.name]);
const savePanelsMut = useMutation({
mutationFn: async () => {
if (draftName.trim() && draftName.trim() !== (board?.name || "")) {
await updatePortTrafficBoard(boardId, { name: draftName.trim(), cols: draftCols });
} else if (draftCols !== (board?.cols || 2)) {
await updatePortTrafficBoard(boardId, { cols: draftCols });
}
return putPortTrafficBoardPanels(
boardId,
draftPanels.map((p, i) => ({ ...panelToIn(p), ord: i })),
);
},
onSuccess: () => {
showOk(t("portTraffic.boardSaved"));
setDirty(false);
setEditing(false);
closePanelSettings();
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoards });
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoard(boardId) });
},
onError: (e: Error) => showError(e.message),
});
const deleteMut = useMutation({
mutationFn: () => deletePortTrafficBoard(boardId),
onSuccess: () => {
showOk(t("portTraffic.boardDeleted"));
setDeleted(true);
setEditing(false);
setDirty(false);
closePanelSettings();
void queryClient.invalidateQueries({ queryKey: queryKeys.portTrafficBoards });
},
onError: (e: Error) => showError(e.message),
});
const startEdit = () => {
if (!board) return;
setDraftPanels([...(board.panels || [])]);
setDraftName(board.name || "");
setDraftCols(board.cols || 2);
setEditing(true);
setDirty(false);
};
const cancelEdit = () => {
if (dirty && !window.confirm(t("portTraffic.boardUnsavedLeave"))) return;
setEditing(false);
setDirty(false);
closePanelSettings();
if (board) {
setDraftPanels(board.panels || []);
setDraftName(board.name || "");
setDraftCols(board.cols || 2);
}
}, [
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);
@ -184,206 +230,461 @@ export function PortTrafficWallPage() {
}
};
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),
});
const markDirty = (next: PortTrafficBoardPanel[]) => {
setDraftPanels(next);
setDirty(true);
};
const addPanel = () => {
if (!addTargetId) {
showError(t("portTraffic.pickPort"));
return;
}
const tgt = (addTargetsQuery.data?.items || []).find((x) => x.id === addTargetId);
const panel: PortTrafficBoardPanel = {
id: newPanelId(),
board_id: boardId,
title: "",
target_id: addTargetId,
range_hours: 24,
baseline: "off",
offset_hours: 0,
baseline_target_id: "",
y_mode: "auto",
ord: draftPanels.length,
col_span: 1,
row_span: 1,
stale: false,
target: tgt || null,
baseline_target: null,
};
markDirty([...draftPanels, panel]);
setAddTargetId("");
};
const updatePanel = (id: string, patch: Partial<PortTrafficBoardPanel>) => {
markDirty(draftPanels.map((p) => (p.id === id ? { ...p, ...patch } : p)));
};
const movePanel = (id: string, dir: -1 | 1) => {
const idx = draftPanels.findIndex((p) => p.id === id);
if (idx < 0) return;
const j = idx + dir;
if (j < 0 || j >= draftPanels.length) return;
const next = [...draftPanels];
const tmp = next[idx];
next[idx] = next[j];
next[j] = tmp;
markDirty(next.map((p, i) => ({ ...p, ord: i })));
};
const activeAddTargets = useMemo(
() => (addTargetsQuery.data?.items || []).filter((x) => x.status === "active"),
[addTargetsQuery.data?.items],
);
if (!boardId) {
return (
<section className="pt-wall-shell">
<div className="pt-wall-shell__empty">
<p>{t("portTraffic.boardPickHint")}</p>
</div>
</section>
);
}
if (deleted) {
return (
<section className="pt-wall-shell">
<div className="pt-wall-shell__empty">
<p>{t("portTraffic.boardDeleted")}</p>
<p className="muted">{t("portTraffic.boardClosedHint")}</p>
</div>
</section>
);
}
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}
<section className={`pt-wall-shell${editing ? " is-editing" : ""}`}>
<div className="pt-board-toolbar">
<div className="pt-board-toolbar__left">
{editing ? (
<>
<label className="pt-board-toolbar__field">
{t("portTraffic.boardName")}
<input
value={draftName}
onChange={(e) => {
setWallDeviceId(e.target.value);
setWallTargetId("");
setDraftName(e.target.value);
setDirty(true);
}}
/>
</label>
<label className="pt-board-toolbar__field pt-board-toolbar__field--sm">
{t("portTraffic.boardCols")}
<select
value={draftCols}
onChange={(e) => {
setDraftCols(Number(e.target.value) || 2);
setDirty(true);
}}
>
{devices.map((d) => (
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
</select>
</label>
</>
) : (
<div className="pt-board-toolbar__title">
{board?.name || t("portTraffic.wallTitle")}
{dirty ? " *" : ""}
</div>
)}
</div>
<div className="btn-row pt-board-toolbar__actions">
{panels.length && !editing ? (
<button
type="button"
className="pt-wall-page__fs-btn pt-wall-page__fs-btn--toolbar"
onClick={() => void toggleFullscreen()}
title={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
aria-label={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
>
<FullscreenIcon />
<span>{fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}</span>
</button>
) : null}
{!editing ? (
<button type="button" className="btn-primary" onClick={startEdit} disabled={!board}>
{t("portTraffic.boardEdit")}
</button>
) : (
<>
<button
type="button"
className="btn-primary"
disabled={savePanelsMut.isPending}
onClick={() => savePanelsMut.mutate()}
>
{t("portTraffic.boardSave")}
</button>
<button type="button" onClick={cancelEdit}>
{t("portTraffic.boardCancel")}
</button>
</>
)}
{!editing ? (
<button
type="button"
className="btn--danger"
disabled={deleteMut.isPending || !board}
onClick={() => {
if (window.confirm(t("portTraffic.boardConfirmDelete"))) deleteMut.mutate();
}}
>
{t("portTraffic.delete")}
</button>
) : null}
</div>
</div>
{boardQuery.isLoading ? (
<div className="pt-wall-shell__empty">
<p>…</p>
</div>
) : boardQuery.isError ? (
<div className="pt-wall-shell__empty">
<p>{(boardQuery.error as Error)?.message || t("common.opFailed")}</p>
</div>
) : (
<div className="pt-wall-page pt-board-page">
{editing ? (
<div className="pt-board-add">
<label className="pt-wall-page__field">
{t("portTraffic.wallDevice")}
<select
value={addDeviceId}
onChange={(e) => {
setAddDeviceId(e.target.value);
setAddTargetId("");
}}
>
<option value="">{t("portTraffic.boardPickDevice")}</option>
{(devicesQuery.data?.items || []).map((d) => (
<option key={d.id} value={d.id}>
{deviceLabel(d)}
{d.ne_ip ? ` (${d.ne_ip})` : ""}
{d.ne_name || d.ne_ip || d.ne_id}
</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)}>
<select value={addTargetId} onChange={(e) => setAddTargetId(e.target.value)}>
<option value="">{t("portTraffic.pickPort")}</option>
{wallTargets.map((tgt) => (
{activeAddTargets.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 type="button" className="btn-primary" onClick={addPanel}>
{t("portTraffic.boardAddPanel")}
</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>
{!panels.length ? (
<div className="pt-wall-shell__empty">
<p>{t("portTraffic.boardNoPanels")}</p>
{!editing ? (
<button type="button" className="btn-primary" onClick={startEdit}>
{t("portTraffic.boardEdit")}
</button>
) : null}
</div>
) : (
<div
ref={stageRef}
className={`pt-wall-page__stage${fullscreen ? " is-fullscreen" : ""}`}
>
<div
className="pt-board-grid"
style={{ gridTemplateColumns: `repeat(${Math.max(1, cols)}, minmax(0, 1fr))` }}
>
{panels.map((p) => (
<PortTrafficBoardPanelCell
key={p.id}
boardId={boardId}
panel={p}
editing={editing}
dense={fullscreen}
onEdit={() => openPanelSettings(p.id)}
onRemove={() => markDirty(draftPanels.filter((x) => x.id !== p.id))}
onMove={(dir) => movePanel(p.id, dir)}
/>
))}
</div>
{fullscreen ? (
<div className="pt-board-fs-legend" aria-hidden={false}>
<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>
<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>
</div>
) : null}
</div>
)}
</div>
)}
{editing && editPanel ? (
<div className="modal-backdrop" role="presentation" onClick={closePanelSettings}>
<div
className="modal"
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}
>
<h3>{t("portTraffic.boardPanelSettings")}</h3>
<div className="form-grid" style={{ marginTop: 12 }}>
<label>
{t("portTraffic.boardPanelTitle")}
<input
value={editPanel.title}
onChange={(e) => updatePanel(editPanel.id, { title: e.target.value })}
/>
</label>
<label>
{t("portTraffic.boardPickDevice")}
<select
value={editTargetsDeviceId}
onChange={(e) => {
setEditPickDeviceId(e.target.value);
updatePanel(editPanel.id, { target_id: "", target: null, stale: true });
}}
>
<option value="">{t("portTraffic.boardPickDevice")}</option>
{(devicesQuery.data?.items || []).map((d) => (
<option key={d.id} value={d.id}>
{d.ne_name || d.ne_ip || d.id}
</option>
))}
</select>
</label>
<label>
{t("portTraffic.wallPort")}
<select
value={editPanel.target_id}
disabled={!editTargetsDeviceId}
onChange={(e) => {
const tid = e.target.value;
const tgt = (editTargetsQuery.data?.items || []).find((x) => x.id === tid);
updatePanel(editPanel.id, {
target_id: tid,
target: tgt || null,
stale: !tgt,
...(tid && tid === editPanel.baseline_target_id
? { baseline_target_id: "", baseline_target: null }
: {}),
});
}}
>
<option value="">{t("portTraffic.pickPort")}</option>
{(editTargetsQuery.data?.items || [])
.filter((x) => x.status === "active" || x.id === editPanel.target_id)
.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</select>
</label>
<label>
{t("portTraffic.range")}
<select
value={editPanel.range_hours}
onChange={(e) =>
updatePanel(editPanel.id, { range_hours: Number(e.target.value) || 24 })
}
>
<option value={1}>1h</option>
<option value={6}>6h</option>
<option value={24}>24h</option>
</select>
</label>
<label>
{t("portTraffic.compare")}
<select
value={editPanel.baseline}
onChange={(e) =>
updatePanel(editPanel.id, { baseline: 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>
{editPanel.baseline === "custom" ? (
<label>
{t("portTraffic.offsetHours")}
<input
type="number"
min={1}
max={24 * 90}
value={editPanel.offset_hours || 48}
onChange={(e) =>
updatePanel(editPanel.id, {
offset_hours: Number(e.target.value) || 24,
})
}
/>
</label>
) : null}
<label>
{t("portTraffic.yMode")}
<select
value={editPanel.y_mode}
onChange={(e) =>
updatePanel(editPanel.id, { y_mode: 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>
<label>
{t("portTraffic.boardColSpan")}
<select
value={editPanel.col_span || 1}
onChange={(e) =>
updatePanel(editPanel.id, {
col_span: Math.min(cols, Number(e.target.value) || 1),
})
}
>
{Array.from({ length: cols }, (_, i) => i + 1).map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
<label>
{t("portTraffic.filterMap")} · {t("portTraffic.wallDevice")}
<select
value={editMapBaselineDeviceId}
disabled={!editPanel.target_id}
onChange={(e) => {
setEditMapBaselineDeviceId(e.target.value);
updatePanel(editPanel.id, {
baseline_target_id: "",
baseline_target: null,
});
}}
>
<option value="">{t("portTraffic.mapBaselineNone")}</option>
{(devicesQuery.data?.items || []).map((d) => (
<option key={d.id} value={d.id}>
{d.ne_name || d.ne_ip || d.id}
</option>
))}
</select>
</label>
<label>
{t("portTraffic.mapBaselinePort")}
<select
value={editPanel.baseline_target_id || ""}
disabled={!editMapBaselineDeviceId}
onChange={(e) => {
const tid = e.target.value;
const tgt = mapBaselineOptions.find((x) => x.id === tid) || null;
updatePanel(editPanel.id, {
baseline_target_id: tid,
baseline_target: tgt,
});
}}
>
<option value="">{t("portTraffic.pickPort")}</option>
{mapBaselineOptions.map((tgt) => (
<option key={tgt.id} value={tgt.id}>
{tgt.ifname}
</option>
))}
</select>
</label>
</div>
{editPanel.baseline === "week" ? (
<p className="muted" style={{ marginTop: 10 }}>
{t("portTraffic.retentionHint")}
</p>
) : null}
<div className="modal__actions">
<button type="button" className="btn-primary" onClick={closePanelSettings}>
{t("portTraffic.logClose")}
</button>
</div>
</div>
</div>
) : null}
</section>
);
}