mirror of
https://github.com/hansjone/netx.git
synced 2026-10-15 04:53:20 +08:00
Refactor web UI: feature-first structure, tokenized styles, modern blue theme.
Structure
- pages/ -> features/<domain>/{Page, components, hooks, lib}; 95 files moved,
333 imports rewritten, verified with tsc.
- services/api.ts -> services/api/<domain>.ts (250 exports preserved),
types.ts -> types/<domain>.ts, i18n zh/en -> locales/<lang>/<namespace>.ts
(2696 messages deep-equal before/after).
- ESLint rule: shared layers must not import features/.
Styles
- index.css (16k lines) split losslessly into styles/{base,layout,components,
pages,legacy,theme}; compiled CSS was byte-identical before tokenizing.
- ~930 hard-coded colors replaced by --nx-* tokens (theme/tokens.css); WebCRT
off-palette colors unified; fixed dark-on-dark text introduced by mapping.
- theme/theme.css: modern blue design language (rounded shapes, gradients,
unified buttons/fields/tables/modals), form-grid alignment fix, flat nav list
without tree indent, soft selected state instead of left accent bar.
Docs: WEB.md documents code structure and style rules.
Not touched: the large Biz*/Webcrt/Topology page components (to be split later).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
3b45d6abe3
commit
0d894efe5e
196 changed files with 26947 additions and 28091 deletions
144
web/src/features/port-traffic/PortTrafficBoardListPage.tsx
Normal file
144
web/src/features/port-traffic/PortTrafficBoardListPage.tsx
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
import { Button } from "@heroui/react";
|
||||
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 "./lib/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 nm-page-panel">
|
||||
<div className="panel__toolbar">
|
||||
<h2>{t("portTraffic.wallTitle")}</h2>
|
||||
<div className="btn-row">
|
||||
<Button
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={createMut.isPending}
|
||||
onPress={() => createMut.mutate()}
|
||||
>
|
||||
{t("portTraffic.boardCreate")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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
|
||||
size="sm"
|
||||
variant="primary"
|
||||
isDisabled={createMut.isPending}
|
||||
onPress={() => 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
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
className="link-btn pt-list-task-name"
|
||||
onPress={() => 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
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
onPress={() => openPortTrafficBoardWindow(b.id)}
|
||||
>
|
||||
{t("portTraffic.boardOpen")}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="danger"
|
||||
isDisabled={deleteMut.isPending}
|
||||
onPress={() => {
|
||||
if (window.confirm(t("portTraffic.boardConfirmDelete"))) {
|
||||
deleteMut.mutate(b.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("portTraffic.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
1548
web/src/features/port-traffic/PortTrafficPage.tsx
Normal file
1548
web/src/features/port-traffic/PortTrafficPage.tsx
Normal file
File diff suppressed because it is too large
Load diff
720
web/src/features/port-traffic/PortTrafficWallPage.tsx
Normal file
720
web/src/features/port-traffic/PortTrafficWallPage.tsx
Normal file
|
|
@ -0,0 +1,720 @@
|
|||
import { Button, Input, Modal } from "@heroui/react";
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useParams } from "react-router-dom";
|
||||
import {
|
||||
deletePortTrafficBoard,
|
||||
fetchPortTrafficBoard,
|
||||
fetchPortTrafficDevices,
|
||||
fetchPortTrafficTargets,
|
||||
putPortTrafficBoardPanels,
|
||||
updatePortTrafficBoard,
|
||||
} from "../../services/api";
|
||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||
import { queryKeys } from "../../constants/queryKeys";
|
||||
import { useI18n } from "../../i18n";
|
||||
import { useToast } from "../../hooks/useToast";
|
||||
import type { PortTrafficBoardPanel, PortTrafficBoardPanelIn } from "../../types";
|
||||
import { PortTrafficBoardPanelCell } from "./components/PortTrafficBoardPanelCell";
|
||||
import type { WallYMode } from "./components/PortTrafficWall";
|
||||
|
||||
type BaselineMode = "off" | "shift" | "day" | "week" | "custom";
|
||||
|
||||
function FullscreenIcon() {
|
||||
return (
|
||||
<svg viewBox="0 0 24 24" width="16" height="16" aria-hidden="true">
|
||||
<path
|
||||
fill="currentColor"
|
||||
d="M7 14H5v5h5v-2H7v-3zm0-9h3V3H5v5h2V5zm12 9h-2v3h-3v2h5v-5zm-2-9V3h-3v2h3v3h2V5h-2z"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
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,
|
||||
ahead_hours: p.ahead_hours ?? 1,
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePanel(p: PortTrafficBoardPanel): PortTrafficBoardPanel {
|
||||
return { ...p, ahead_hours: p.ahead_hours ?? 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 { boardId: boardIdParam = "" } = useParams<{ boardId: string }>();
|
||||
const boardId = decodeURIComponent(boardIdParam).trim();
|
||||
const stageRef = useRef<HTMLDivElement | null>(null);
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
const addTargetsQuery = useQuery({
|
||||
queryKey: queryKeys.portTrafficTargets(addDeviceId),
|
||||
queryFn: () => fetchPortTrafficTargets(addDeviceId),
|
||||
enabled: editing && Boolean(addDeviceId),
|
||||
staleTime: 2000,
|
||||
});
|
||||
|
||||
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(editMapBaselineDeviceId),
|
||||
queryFn: () => fetchPortTrafficTargets(editMapBaselineDeviceId),
|
||||
enabled: editing && Boolean(editPanelId) && Boolean(editMapBaselineDeviceId),
|
||||
staleTime: 2000,
|
||||
});
|
||||
|
||||
const mapBaselineOptions = useMemo(
|
||||
() =>
|
||||
(mapBaselineTargetsQuery.data?.items || []).filter(
|
||||
(x) =>
|
||||
(x.status === "active" || x.id === editPanel?.baseline_target_id) &&
|
||||
x.id !== editPanel?.target_id,
|
||||
),
|
||||
[mapBaselineTargetsQuery.data?.items, editPanel?.baseline_target_id, editPanel?.target_id],
|
||||
);
|
||||
|
||||
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 (!board || editing) return;
|
||||
setDraftPanels((board.panels || []).map(normalizePanel));
|
||||
setDraftName(board.name || "");
|
||||
setDraftCols(board.cols || 2);
|
||||
}, [board, editing]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!board?.name) return;
|
||||
try {
|
||||
document.title = "NETX";
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}, [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 || []).map(normalizePanel));
|
||||
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 || []).map(normalizePanel));
|
||||
setDraftName(board.name || "");
|
||||
setDraftCols(board.cols || 2);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const syncFs = () => {
|
||||
const el = stageRef.current;
|
||||
setFullscreen(Boolean(el && document.fullscreenElement === el));
|
||||
};
|
||||
document.addEventListener("fullscreenchange", syncFs);
|
||||
return () => document.removeEventListener("fullscreenchange", syncFs);
|
||||
}, []);
|
||||
|
||||
const toggleFullscreen = async () => {
|
||||
const el = stageRef.current;
|
||||
if (!el) return;
|
||||
try {
|
||||
if (document.fullscreenElement === el) {
|
||||
await document.exitFullscreen();
|
||||
} else {
|
||||
await el.requestFullscreen();
|
||||
}
|
||||
} catch (err) {
|
||||
showError(String(err));
|
||||
}
|
||||
};
|
||||
|
||||
const 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,
|
||||
ahead_hours: 1,
|
||||
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${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) => {
|
||||
setDraftName(e.target.value);
|
||||
setDirty(true);
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<FieldSelect
|
||||
className="pt-board-toolbar__field pt-board-toolbar__field--sm"
|
||||
label={t("portTraffic.boardCols")}
|
||||
value={draftCols}
|
||||
onChange={(e) => {
|
||||
setDraftCols(Number(e.target.value) || 2);
|
||||
setDirty(true);
|
||||
}}
|
||||
>
|
||||
<option value={1}>1</option>
|
||||
<option value={2}>2</option>
|
||||
<option value={3}>3</option>
|
||||
<option value={4}>4</option>
|
||||
</FieldSelect>
|
||||
</>
|
||||
) : (
|
||||
<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
|
||||
size="sm"
|
||||
variant="secondary"
|
||||
className="pt-wall-page__fs-btn pt-wall-page__fs-btn--toolbar"
|
||||
onPress={() => void toggleFullscreen()}
|
||||
aria-label={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
|
||||
>
|
||||
<FullscreenIcon />
|
||||
<span>{fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{!editing ? (
|
||||
<Button variant="primary" onPress={startEdit} isDisabled={!board}>
|
||||
{t("portTraffic.boardEdit")}
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
variant="primary"
|
||||
isDisabled={savePanelsMut.isPending}
|
||||
onPress={() => savePanelsMut.mutate()}
|
||||
>
|
||||
{t("portTraffic.boardSave")}
|
||||
</Button>
|
||||
<Button variant="tertiary" onPress={cancelEdit}>
|
||||
{t("portTraffic.boardCancel")}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!editing ? (
|
||||
<Button
|
||||
variant="danger"
|
||||
isDisabled={deleteMut.isPending || !board}
|
||||
onPress={() => {
|
||||
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}>
|
||||
{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={addTargetId} onChange={(e) => setAddTargetId(e.target.value)}>
|
||||
<option value="">{t("portTraffic.pickPort")}</option>
|
||||
{activeAddTargets.map((tgt) => (
|
||||
<option key={tgt.id} value={tgt.id}>
|
||||
{tgt.ifname}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<Button variant="primary" onPress={addPanel}>
|
||||
{t("portTraffic.boardAddPanel")}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!panels.length ? (
|
||||
<div className="pt-wall-shell__empty">
|
||||
<p>{t("portTraffic.boardNoPanels")}</p>
|
||||
{!editing ? (
|
||||
<Button variant="primary" onPress={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 ? (
|
||||
<AppModalShell open onClose={closePanelSettings} size="lg">
|
||||
<Modal.Header>
|
||||
<Modal.Heading>{t("portTraffic.boardPanelSettings")}</Modal.Heading>
|
||||
<Modal.CloseTrigger />
|
||||
</Modal.Header>
|
||||
<Modal.Body className="flex flex-col gap-3">
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
{t("portTraffic.boardPanelTitle")}
|
||||
<Input
|
||||
value={editPanel.title}
|
||||
onChange={(e) => updatePanel(editPanel.id, { title: e.target.value })}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.boardPickDevice")}
|
||||
<FieldSelect
|
||||
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>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.wallPort")}
|
||||
<FieldSelect
|
||||
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>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.range")}
|
||||
<FieldSelect
|
||||
value={String(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>
|
||||
</FieldSelect>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.compare")}
|
||||
<FieldSelect
|
||||
value={editPanel.baseline}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as BaselineMode;
|
||||
updatePanel(editPanel.id, {
|
||||
baseline: next,
|
||||
...(next !== "off" && !(editPanel.ahead_hours > 0)
|
||||
? { ahead_hours: 1 }
|
||||
: {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</FieldSelect>
|
||||
</label>
|
||||
{editPanel.baseline === "custom" ? (
|
||||
<label>
|
||||
{t("portTraffic.offsetHours")}
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
max={24 * 90}
|
||||
value={String(editPanel.offset_hours || 48)}
|
||||
onChange={(e) =>
|
||||
updatePanel(editPanel.id, {
|
||||
offset_hours: Number(e.target.value) || 24,
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
<label>
|
||||
{t("portTraffic.yMode")}
|
||||
<FieldSelect
|
||||
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>
|
||||
</FieldSelect>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.boardColSpan")}
|
||||
<FieldSelect
|
||||
value={String(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>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.filterMap")} · {t("portTraffic.wallDevice")}
|
||||
<FieldSelect
|
||||
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>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</label>
|
||||
<label>
|
||||
{t("portTraffic.mapBaselinePort")}
|
||||
<FieldSelect
|
||||
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,
|
||||
...(tid && !(editPanel.ahead_hours > 0) ? { ahead_hours: 1 } : {}),
|
||||
});
|
||||
}}
|
||||
>
|
||||
<option value="">{t("portTraffic.pickPort")}</option>
|
||||
{mapBaselineOptions.map((tgt) => (
|
||||
<option key={tgt.id} value={tgt.id}>
|
||||
{tgt.ifname}
|
||||
</option>
|
||||
))}
|
||||
</FieldSelect>
|
||||
</label>
|
||||
{editPanel.baseline !== "off" || editPanel.baseline_target_id ? (
|
||||
<label>
|
||||
{t("portTraffic.aheadHours")}
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={24}
|
||||
step={1}
|
||||
aria-label={t("portTraffic.aheadHoursHint")}
|
||||
value={String(editPanel.ahead_hours ?? 1)}
|
||||
onChange={(e) =>
|
||||
updatePanel(editPanel.id, {
|
||||
ahead_hours: Math.max(0, Math.min(24, Number(e.target.value) || 0)),
|
||||
})
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
</div>
|
||||
{editPanel.baseline === "week" ? (
|
||||
<p className="muted">{t("portTraffic.retentionHint")}</p>
|
||||
) : null}
|
||||
{editPanel.baseline_target_id ? (
|
||||
<p className="muted">{t("portTraffic.mapBaselineHint")}</p>
|
||||
) : null}
|
||||
</Modal.Body>
|
||||
<Modal.Footer>
|
||||
<Button variant="primary" onPress={closePanelSettings}>
|
||||
{t("portTraffic.logClose")}
|
||||
</Button>
|
||||
</Modal.Footer>
|
||||
</AppModalShell>
|
||||
) : null}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import { Button } from "@heroui/react";
|
||||
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 aheadHours =
|
||||
baseline === "off" && !panel.baseline_target_id
|
||||
? 0
|
||||
: Math.max(0, Number(panel.ahead_hours ?? 1) || 0);
|
||||
const compareQuery = useQuery({
|
||||
queryKey: [
|
||||
...queryKeys.portTrafficCompare(
|
||||
panel.target_id,
|
||||
panel.range_hours,
|
||||
baseline,
|
||||
baseline === "custom" ? panel.offset_hours : 0,
|
||||
panel.baseline_target_id || "",
|
||||
aheadHours,
|
||||
),
|
||||
boardId,
|
||||
panel.id,
|
||||
],
|
||||
queryFn: () =>
|
||||
fetchPortTrafficCompare({
|
||||
targetId: panel.target_id,
|
||||
rangeHours: panel.range_hours,
|
||||
baseline,
|
||||
offsetHours: baseline === "custom" ? panel.offset_hours : undefined,
|
||||
aheadHours: aheadHours || 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 size="sm" variant="secondary" isIconOnly onPress={() => onMove?.(-1)}>
|
||||
↑
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" isIconOnly onPress={() => onMove?.(1)}>
|
||||
↓
|
||||
</Button>
|
||||
<Button size="sm" variant="secondary" onPress={() => onEdit?.()}>
|
||||
{t("portTraffic.edit")}
|
||||
</Button>
|
||||
<Button size="sm" variant="danger" onPress={() => onRemove?.()}>
|
||||
{t("portTraffic.delete")}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{panel.stale ? (
|
||||
<div className="pt-board-panel__stale">
|
||||
<p>{t("portTraffic.boardPanelStale")}</p>
|
||||
{editing ? (
|
||||
<Button variant="primary" onPress={() => 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>
|
||||
);
|
||||
}
|
||||
536
web/src/features/port-traffic/components/PortTrafficWall.tsx
Normal file
536
web/src/features/port-traffic/components/PortTrafficWall.tsx
Normal file
|
|
@ -0,0 +1,536 @@
|
|||
import { useEffect, useMemo, useRef } from "react";
|
||||
import uPlot from "uplot";
|
||||
import "uplot/dist/uPlot.min.css";
|
||||
import type { PortTrafficSamplePoint, PortTrafficTarget } from "../../../types";
|
||||
import { formatSystemTime, parseApiTime } from "../../../utils/time";
|
||||
import { useI18n } from "../../../i18n";
|
||||
|
||||
export type WallYMode = "auto" | "current" | "util";
|
||||
|
||||
function formatBps(n: number): string {
|
||||
const v = Math.abs(n);
|
||||
if (v >= 1e9) return `${(n / 1e9).toFixed(2)} G`;
|
||||
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)}`;
|
||||
}
|
||||
|
||||
function formatBwLabel(bps: number): string {
|
||||
if (!bps) return "—";
|
||||
return `${formatBps(bps)}bit/s`;
|
||||
}
|
||||
|
||||
function formatUtilPct(n: number): string {
|
||||
if (!Number.isFinite(n) || n <= 0) return "0%";
|
||||
if (n < 0.01) return "<0.01%";
|
||||
if (n < 1) return `${n.toFixed(2)}%`;
|
||||
return `${n.toFixed(1)}%`;
|
||||
}
|
||||
|
||||
function resolveUtil(vendorUtil: number, bps: number, bw: number): number {
|
||||
if (vendorUtil > 0) return vendorUtil;
|
||||
if (bw > 0 && bps > 0) return (bps / bw) * 100;
|
||||
return vendorUtil || 0;
|
||||
}
|
||||
|
||||
function formatHoverTime(sec: number): string {
|
||||
return formatSystemTime(new Date(sec * 1000));
|
||||
}
|
||||
|
||||
function yPadRange(min: number, max: number): [number, number] {
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) return [0, 1];
|
||||
if (min === max) {
|
||||
const pad = Math.max(max * 0.25 || 0.1, max > 0 && max < 1 ? max * 0.5 : 10);
|
||||
return [Math.max(0, min - pad * 0.1), max + pad];
|
||||
}
|
||||
return [Math.max(0, min * 0.95), max * 1.05];
|
||||
}
|
||||
|
||||
function finiteMax(vals: number[]): number {
|
||||
let m = Number.NEGATIVE_INFINITY;
|
||||
for (const v of vals) {
|
||||
if (Number.isFinite(v) && v > m) m = v;
|
||||
}
|
||||
return Number.isFinite(m) ? m : 0;
|
||||
}
|
||||
|
||||
function pointValue(
|
||||
p: PortTrafficSamplePoint,
|
||||
dir: "in" | "out",
|
||||
mode: WallYMode,
|
||||
bwFallback: number,
|
||||
): number {
|
||||
const bps = dir === "in" ? Number(p.in_bps) || 0 : Number(p.out_bps) || 0;
|
||||
if (mode !== "util") return bps;
|
||||
const bw = Number(p.bw_bps) || bwFallback || 0;
|
||||
const vendor = dir === "in" ? Number(p.in_util_pct) || 0 : Number(p.out_util_pct) || 0;
|
||||
return resolveUtil(vendor, bps, bw);
|
||||
}
|
||||
|
||||
function toAlignedSeries(
|
||||
current: PortTrafficSamplePoint[],
|
||||
baseline: PortTrafficSamplePoint[],
|
||||
mode: WallYMode,
|
||||
bwFallback: number,
|
||||
baseBwFallback: number,
|
||||
): [number[], number[], number[], number[], number[]] {
|
||||
type Row = { x: number; inC?: number; outC?: number; inB?: number; outB?: number };
|
||||
// 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);
|
||||
if (!d) continue;
|
||||
const x = Math.floor(d.getTime() / 1000);
|
||||
const row = map.get(x) || { x };
|
||||
row.inC = pointValue(p, "in", mode, bwFallback);
|
||||
row.outC = pointValue(p, "out", mode, bwFallback);
|
||||
map.set(x, row);
|
||||
}
|
||||
for (const p of baseline) {
|
||||
const d = parseApiTime(p.ts);
|
||||
if (!d) continue;
|
||||
const x = Math.floor(d.getTime() / 1000);
|
||||
const row = map.get(x) || { x };
|
||||
row.inB = pointValue(p, "in", mode, baseBwFallback || bwFallback);
|
||||
row.outB = pointValue(p, "out", mode, baseBwFallback || bwFallback);
|
||||
map.set(x, row);
|
||||
}
|
||||
const rows = [...map.values()].sort((a, b) => a.x - b.x);
|
||||
if (rows.length === 1) {
|
||||
rows.push({ ...rows[0], x: rows[0].x + 60 });
|
||||
}
|
||||
return [
|
||||
rows.map((r) => r.x),
|
||||
rows.map((r) => r.inC ?? NaN),
|
||||
rows.map((r) => r.outC ?? NaN),
|
||||
rows.map((r) => r.inB ?? NaN),
|
||||
rows.map((r) => r.outB ?? NaN),
|
||||
];
|
||||
}
|
||||
|
||||
function chartSize(el: HTMLElement): { width: number; height: number } {
|
||||
const width = Math.max(120, el.clientWidth || el.parentElement?.clientWidth || 800);
|
||||
const parentH = el.clientHeight || el.parentElement?.clientHeight || 0;
|
||||
// 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[] = [];
|
||||
|
||||
type Props = {
|
||||
target: PortTrafficTarget | null;
|
||||
baselineTarget?: PortTrafficTarget | null;
|
||||
points: PortTrafficSamplePoint[];
|
||||
baselinePoints?: PortTrafficSamplePoint[];
|
||||
rangeLabel: string;
|
||||
yMode?: WallYMode;
|
||||
loading?: boolean;
|
||||
hint?: string;
|
||||
/** Board fullscreen: denser chrome, no per-panel legend. */
|
||||
dense?: boolean;
|
||||
};
|
||||
|
||||
export function PortTrafficWall({
|
||||
target,
|
||||
baselineTarget = null,
|
||||
points,
|
||||
baselinePoints = EMPTY_POINTS,
|
||||
rangeLabel,
|
||||
yMode = "auto",
|
||||
loading,
|
||||
hint,
|
||||
dense = false,
|
||||
}: Props) {
|
||||
const { t } = useI18n();
|
||||
const mountRef = useRef<HTMLDivElement | null>(null);
|
||||
const plotRef = useRef<uPlot | null>(null);
|
||||
const tipRef = useRef<HTMLDivElement | null>(null);
|
||||
const pointsRef = useRef(points);
|
||||
const baselineRef = useRef(baselinePoints);
|
||||
const yModeRef = useRef(yMode);
|
||||
const bwRef = useRef(0);
|
||||
pointsRef.current = points;
|
||||
baselineRef.current = baselinePoints;
|
||||
yModeRef.current = yMode;
|
||||
|
||||
const latest = points.length ? points[points.length - 1] : null;
|
||||
const kpi = useMemo(() => {
|
||||
const bw = latest?.bw_bps || target?.bw_bps || 0;
|
||||
const inBps = latest?.in_bps ?? 0;
|
||||
const outBps = latest?.out_bps ?? 0;
|
||||
return {
|
||||
bw,
|
||||
inBps,
|
||||
outBps,
|
||||
inUtil: resolveUtil(latest?.in_util_pct ?? 0, inBps, bw),
|
||||
outUtil: resolveUtil(latest?.out_util_pct ?? 0, outBps, bw),
|
||||
ts: latest?.ts ?? null,
|
||||
};
|
||||
}, [latest, target]);
|
||||
bwRef.current = kpi.bw;
|
||||
|
||||
useEffect(() => {
|
||||
const el = mountRef.current;
|
||||
if (!el) return;
|
||||
|
||||
const { width, height } = chartSize(el);
|
||||
|
||||
const placeTip = (u: uPlot, left: number, top: number) => {
|
||||
const tip = tipRef.current;
|
||||
if (!tip) return;
|
||||
const wrap = tip.parentElement;
|
||||
if (!wrap) return;
|
||||
const overRect = u.over.getBoundingClientRect();
|
||||
const wrapRect = wrap.getBoundingClientRect();
|
||||
const tipW = tip.offsetWidth || 160;
|
||||
const tipH = tip.offsetHeight || 72;
|
||||
let x = overRect.left - wrapRect.left + left + 14;
|
||||
let y = overRect.top - wrapRect.top + top - tipH - 8;
|
||||
x = Math.min(Math.max(8, x), Math.max(8, wrapRect.width - tipW - 8));
|
||||
y = Math.max(8, y);
|
||||
tip.style.transform = `translate(${x}px, ${y}px)`;
|
||||
};
|
||||
|
||||
const opts: uPlot.Options = {
|
||||
width,
|
||||
height,
|
||||
series: [
|
||||
{},
|
||||
{
|
||||
label: t("portTraffic.seriesCurrentIn"),
|
||||
stroke: "#2dd4bf",
|
||||
width: 2,
|
||||
fill: "rgba(45, 212, 191, 0.08)",
|
||||
points: { show: false },
|
||||
spanGaps: true,
|
||||
},
|
||||
{
|
||||
label: t("portTraffic.seriesCurrentOut"),
|
||||
stroke: "#fbbf24",
|
||||
width: 2,
|
||||
fill: "rgba(251, 191, 36, 0.07)",
|
||||
points: { show: false },
|
||||
spanGaps: true,
|
||||
},
|
||||
{
|
||||
label: t("portTraffic.seriesBaselineIn"),
|
||||
stroke: "rgba(45, 212, 191, 0.45)",
|
||||
width: 1.5,
|
||||
dash: [6, 4],
|
||||
points: { show: false },
|
||||
spanGaps: true,
|
||||
show: false,
|
||||
},
|
||||
{
|
||||
label: t("portTraffic.seriesBaselineOut"),
|
||||
stroke: "rgba(251, 191, 36, 0.4)",
|
||||
width: 1.5,
|
||||
dash: [6, 4],
|
||||
points: { show: false },
|
||||
spanGaps: true,
|
||||
show: false,
|
||||
},
|
||||
],
|
||||
axes: [
|
||||
{
|
||||
stroke: "#94a3b8",
|
||||
grid: { show: false },
|
||||
ticks: { stroke: "rgba(148,163,184,0.35)", size: 4 },
|
||||
border: { show: false },
|
||||
gap: 6,
|
||||
},
|
||||
{
|
||||
stroke: "#94a3b8",
|
||||
grid: { stroke: "rgba(148,163,184,0.12)", width: 1 },
|
||||
ticks: { stroke: "rgba(148,163,184,0.35)" },
|
||||
border: { show: false },
|
||||
values: (_u, splits) =>
|
||||
splits.map((v) => (yModeRef.current === "util" ? `${v}` : formatBps(v))),
|
||||
size: 64,
|
||||
label: yMode === "util" ? "%" : "bit/s",
|
||||
labelFont: "12px sans-serif",
|
||||
labelSize: 14,
|
||||
},
|
||||
],
|
||||
scales: {
|
||||
x: { time: true },
|
||||
y: {
|
||||
auto: true,
|
||||
range: (u, min, max) => {
|
||||
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);
|
||||
},
|
||||
},
|
||||
},
|
||||
legend: { show: false },
|
||||
cursor: {
|
||||
drag: { x: true, y: false },
|
||||
focus: { prox: 32 },
|
||||
points: { show: true, size: 8 },
|
||||
},
|
||||
hooks: {
|
||||
setCursor: [
|
||||
(u) => {
|
||||
const tip = tipRef.current;
|
||||
const idx = u.cursor.idx;
|
||||
const left = u.cursor.left ?? 0;
|
||||
const top = u.cursor.top ?? 0;
|
||||
|
||||
if (idx == null || idx < 0 || !u.data[0]?.length) {
|
||||
if (tip) tip.style.display = "none";
|
||||
return;
|
||||
}
|
||||
|
||||
const tsSec = Number(u.data[0][idx]);
|
||||
const sample = pointsRef.current.find((p) => {
|
||||
const d = parseApiTime(p.ts);
|
||||
return d && Math.floor(d.getTime() / 1000) === tsSec;
|
||||
});
|
||||
const baseSample = baselineRef.current.find((p) => {
|
||||
const d = parseApiTime(p.ts);
|
||||
return d && Math.floor(d.getTime() / 1000) === tsSec;
|
||||
});
|
||||
const mode = yModeRef.current;
|
||||
const yIn = Number(u.data[1]?.[idx]);
|
||||
const yOut = Number(u.data[2]?.[idx]);
|
||||
const yInB = Number(u.data[3]?.[idx]);
|
||||
const yOutB = Number(u.data[4]?.[idx]);
|
||||
const unit = mode === "util" ? "%" : "bit/s";
|
||||
const fmtY = (v: number) =>
|
||||
mode === "util" ? formatUtilPct(v).replace(/%$/, "") : formatBps(v);
|
||||
|
||||
if (!tip) return;
|
||||
tip.style.display = "block";
|
||||
const rows = [
|
||||
`<div class="pt-wall__tip-time">${formatHoverTime(tsSec)}</div>`,
|
||||
Number.isFinite(yIn)
|
||||
? `<div class="pt-wall__tip-row pt-wall__tip-row--in"><span>In</span><strong>${fmtY(yIn)} ${unit}</strong></div>`
|
||||
: "",
|
||||
Number.isFinite(yOut)
|
||||
? `<div class="pt-wall__tip-row pt-wall__tip-row--out"><span>Out</span><strong>${fmtY(yOut)} ${unit}</strong></div>`
|
||||
: "",
|
||||
];
|
||||
if (sample) {
|
||||
const bw = Number(sample.bw_bps) || bwRef.current || 0;
|
||||
const inU = resolveUtil(Number(sample.in_util_pct) || 0, Number(sample.in_bps) || 0, bw);
|
||||
const outU = resolveUtil(Number(sample.out_util_pct) || 0, Number(sample.out_bps) || 0, bw);
|
||||
rows.push(
|
||||
`<div class="pt-wall__tip-row"><span>Util</span><strong>${formatUtilPct(inU)} / ${formatUtilPct(outU)}</strong></div>`,
|
||||
);
|
||||
}
|
||||
if (Number.isFinite(yInB) || Number.isFinite(yOutB)) {
|
||||
rows.push(
|
||||
`<div class="pt-wall__tip-row"><span>${t("portTraffic.baseline")}</span><strong>${
|
||||
Number.isFinite(yInB) ? fmtY(yInB) : "—"
|
||||
} / ${Number.isFinite(yOutB) ? fmtY(yOutB) : "—"} ${unit}</strong></div>`,
|
||||
);
|
||||
if (baseSample?.ts_raw) {
|
||||
rows.push(
|
||||
`<div class="pt-wall__tip-row"><span>${t("portTraffic.baselineAt")}</span><strong>${formatSystemTime(baseSample.ts_raw)}</strong></div>`,
|
||||
);
|
||||
}
|
||||
}
|
||||
tip.innerHTML = rows.join("");
|
||||
placeTip(u, left, top);
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
plotRef.current = new uPlot(opts, [[], [], [], [], []], el);
|
||||
|
||||
const onLeave = () => {
|
||||
if (tipRef.current) tipRef.current.style.display = "none";
|
||||
};
|
||||
plotRef.current.over.addEventListener("mouseleave", onLeave);
|
||||
|
||||
const onResize = () => {
|
||||
if (!plotRef.current || !mountRef.current) return;
|
||||
plotRef.current.setSize(chartSize(mountRef.current));
|
||||
};
|
||||
window.addEventListener("resize", onResize);
|
||||
document.addEventListener("fullscreenchange", onResize);
|
||||
const ro =
|
||||
typeof ResizeObserver !== "undefined"
|
||||
? new ResizeObserver(() => onResize())
|
||||
: null;
|
||||
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();
|
||||
plotRef.current = null;
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const plot = plotRef.current;
|
||||
if (!plot) return;
|
||||
const bw = kpi.bw || target?.bw_bps || 0;
|
||||
const baseBw = baselineTarget?.bw_bps || bw;
|
||||
const data = toAlignedSeries(points, baselinePoints, yMode, bw, baseBw);
|
||||
const showBaseline = baselinePoints.length > 0;
|
||||
plot.setData(data, true);
|
||||
const s3 = Boolean(plot.series[3]?.show);
|
||||
const s4 = Boolean(plot.series[4]?.show);
|
||||
if (s3 !== showBaseline || s4 !== showBaseline) {
|
||||
plot.setSeries(3, { show: showBaseline }, false);
|
||||
plot.setSeries(4, { show: showBaseline }, false);
|
||||
}
|
||||
const yAxis = plot.axes[1];
|
||||
if (yAxis) {
|
||||
const nextLabel = yMode === "util" ? "%" : "bit/s";
|
||||
if (yAxis.label !== nextLabel) {
|
||||
yAxis.label = nextLabel;
|
||||
plot.redraw(false, false);
|
||||
}
|
||||
}
|
||||
const el = mountRef.current;
|
||||
if (el) {
|
||||
requestAnimationFrame(() => {
|
||||
if (!plotRef.current || !mountRef.current) return;
|
||||
plotRef.current.setSize(chartSize(mountRef.current));
|
||||
});
|
||||
}
|
||||
}, [points, baselinePoints, yMode, kpi.bw, target?.bw_bps, baselineTarget?.bw_bps]);
|
||||
|
||||
const empty = points.length === 0 && baselinePoints.length === 0;
|
||||
const neLabel = target ? target.ne_name || target.ne_ip || "—" : "—";
|
||||
const ifLabel = target?.ifname || "—";
|
||||
const ipLabel = target?.ne_ip || "";
|
||||
const showBaselineMeta = Boolean(baselineTarget) && baselinePoints.length > 0;
|
||||
const baseNeLabel = baselineTarget
|
||||
? baselineTarget.ne_name || baselineTarget.ne_ip || "—"
|
||||
: "—";
|
||||
const baseIfLabel = baselineTarget?.ifname || "—";
|
||||
const baseIpLabel = baselineTarget?.ne_ip || "";
|
||||
|
||||
return (
|
||||
<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}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-wall__range">
|
||||
{rangeLabel}
|
||||
{kpi.ts ? ` · ${formatSystemTime(kpi.ts)}` : ""}
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-wall__kpis">
|
||||
<div className="pt-wall__kpi">
|
||||
<div className="pt-wall__kpi-label">{t("portTraffic.kpiBw")}</div>
|
||||
<div className="pt-wall__kpi-value">{formatBwLabel(kpi.bw)}</div>
|
||||
</div>
|
||||
<div className="pt-wall__kpi pt-wall__kpi--in">
|
||||
<div className="pt-wall__kpi-label">In</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 pt-wall__kpi--util">
|
||||
<div className="pt-wall__kpi-label">Util</div>
|
||||
<div className="pt-wall__kpi-value">
|
||||
{formatUtilPct(kpi.inUtil)}
|
||||
<span className="pt-wall__kpi-sep">/</span>
|
||||
{formatUtilPct(kpi.outUtil)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="pt-wall__chart-wrap">
|
||||
<div className="pt-wall__chart" ref={mountRef} />
|
||||
<div className="pt-wall__tip" ref={tipRef} style={{ display: "none" }} />
|
||||
{empty ? (
|
||||
<div className="pt-wall__empty">
|
||||
{loading ? "Loading samples…" : hint || "Waiting for samples…"}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{!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 ? (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
17
web/src/features/port-traffic/lib/portTrafficBoardWindow.ts
Normal file
17
web/src/features/port-traffic/lib/portTrafficBoardWindow.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
import { openNewModuleWindow } from "../../../utils/moduleWindows";
|
||||
|
||||
export const PORT_TRAFFIC_WALL_MODULE_ID = "port-traffic-wall";
|
||||
|
||||
export function portTrafficBoardPath(boardId: string): string {
|
||||
return `/port-traffic/wall/${encodeURIComponent(boardId)}`;
|
||||
}
|
||||
|
||||
/** Open a dedicated browser tab for one board (outside Network management). */
|
||||
export function openPortTrafficBoardWindow(boardId: string): void {
|
||||
const id = String(boardId || "").trim();
|
||||
if (!id) return;
|
||||
openNewModuleWindow({
|
||||
moduleId: PORT_TRAFFIC_WALL_MODULE_ID,
|
||||
path: portTrafficBoardPath(id),
|
||||
});
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue