Polish HeroUI chrome, topology toolbar, and session client IP.

Checkpoint before workbench facade redesign: list defaults, API key quota, toast portal, topology canvas editor toolbar with More menu, and trusted-proxy client IP for sessions.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-12 15:10:57 +08:00
parent 5096117ad4
commit c4526e36d8
64 changed files with 6316 additions and 4521 deletions

View file

@ -1,8 +1,11 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager";
import { HelpHint } from "../../components/HelpHint";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
@ -32,7 +35,7 @@ import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
const POLL_MS = 2500;
const TARGET_PAGE_SIZE = 20;
const TARGET_PAGE_SIZE = 10;
const SEP = " · ";
function lldpJobStatusLabel(t: (k: string) => string, status: string): string {
@ -99,14 +102,14 @@ export function LldpLinksPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [jobPage, setJobPage] = useState(1);
const [jobPageSize, setJobPageSize] = useState(20);
const [jobPageSize, setJobPageSize] = useState(10);
const [jobStatus, setJobStatus] = useState("");
const [jobKeyword, setJobKeyword] = useState("");
const [exportingJobs, setExportingJobs] = useState(false);
const debouncedJobKeyword = useDebouncedValue(jobKeyword, 300);
const [expandedJobId, setExpandedJobId] = useState("");
const [itemPage, setItemPage] = useState(1);
const [itemPageSize, setItemPageSize] = useState(20);
const [itemPageSize, setItemPageSize] = useState(10);
const [itemStatus, setItemStatus] = useState("");
const [itemKeyword, setItemKeyword] = useState("");
const [exportingItems, setExportingItems] = useState(false);
@ -117,7 +120,7 @@ export function LldpLinksPage() {
const [edgeSource, setEdgeSource] = useState("");
const [edgeKeyword, setEdgeKeyword] = useState("");
const [edgePage, setEdgePage] = useState(1);
const [edgePageSize, setEdgePageSize] = useState(50);
const [edgePageSize, setEdgePageSize] = useState(10);
const [exportingEdges, setExportingEdges] = useState(false);
const debouncedEdgeKeyword = useDebouncedValue(edgeKeyword, 300);
@ -554,45 +557,62 @@ export function LldpLinksPage() {
<div className="panel__toolbar">
<h2>{t("lldpLinks.title")}</h2>
<div className="btn-row">
<button type="button" onClick={() => void refresh()} disabled={dashQuery.isFetching}>
<Button
size="sm"
variant="secondary"
isDisabled={dashQuery.isFetching}
onPress={() => void refresh()}
>
{t("common.refresh")}
</button>
<button
type="button"
className="btn-primary"
disabled={Boolean(running) || startMut.isPending}
onClick={() => startMut.mutate("full")}
</Button>
<Button
size="sm"
variant="primary"
isDisabled={Boolean(running) || startMut.isPending}
onPress={() => startMut.mutate("full")}
>
{t("lldpLinks.collectNow")}
</button>
<button
type="button"
disabled={Boolean(running) || startMut.isPending || !(last && (last.fail_count || 0) > 0)}
onClick={() => startMut.mutate("retry_failed")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={Boolean(running) || startMut.isPending || !(last && (last.fail_count || 0) > 0)}
onPress={() => startMut.mutate("retry_failed")}
>
{t("lldpLinks.retryFailed")}
</button>
</Button>
{running?.status === "running" || running?.status === "pending" ? (
<button type="button" onClick={() => pauseMut.mutate(running.id)} disabled={pauseMut.isPending}>
<Button
size="sm"
variant="secondary"
isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(running.id)}
>
{t("lldpLinks.pause")}
</button>
</Button>
) : null}
{running?.status === "paused" ? (
<button type="button" onClick={() => resumeMut.mutate(running.id)} disabled={resumeMut.isPending}>
<Button
size="sm"
variant="secondary"
isDisabled={resumeMut.isPending}
onPress={() => resumeMut.mutate(running.id)}
>
{t("lldpLinks.resume")}
</button>
</Button>
) : null}
{running &&
(running.status === "running" || running.status === "paused" || running.status === "pending") ? (
<button
type="button"
onClick={() => {
<Button
size="sm"
variant="danger"
isDisabled={stopMut.isPending}
onPress={() => {
if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(running.id);
}}
disabled={stopMut.isPending}
>
{t("lldpLinks.stop")}
</button>
</Button>
) : null}
</div>
</div>
@ -654,11 +674,11 @@ export function LldpLinksPage() {
</label>
<label className="config-sync-policy-field">
<span>{t("lldpLinks.interval")}</span>
<input
<Input
type="number"
min={1}
max={intervalUnit === "days" ? 365 : 8760}
value={intervalValue}
value={String(intervalValue)}
onChange={(e) => {
const max = intervalUnit === "days" ? 365 : 8760;
setIntervalValue(Math.max(1, Math.min(max, Number(e.target.value) || 1)));
@ -711,14 +731,13 @@ export function LldpLinksPage() {
<option value="selected">{t("lldpLinks.scopeSelected")}</option>
</select>
</label>
<button
type="button"
className="btn-primary"
disabled={savePolicyMut.isPending}
onClick={() => savePolicyMut.mutate()}
<Button
variant="primary"
isDisabled={savePolicyMut.isPending}
onPress={() => savePolicyMut.mutate()}
>
{t("lldpLinks.savePolicy")}
</button>
</Button>
</div>
{scopeMode === "selected" ? (
@ -767,9 +786,14 @@ export function LldpLinksPage() {
</tbody>
</table>
<div className="pager">
<button type="button" disabled={targetPage <= 1} onClick={() => setTargetPage((p) => p - 1)}>
<Button
size="sm"
variant="ghost"
isDisabled={targetPage <= 1}
onPress={() => setTargetPage((p) => p - 1)}
>
{t("common.prevPage")}
</button>
</Button>
<span className="muted">
{t("common.pagerMeta", {
total: String(targetsQuery.data?.total ?? 0),
@ -777,13 +801,14 @@ export function LldpLinksPage() {
pages: String(pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)),
})}
</span>
<button
type="button"
disabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
onClick={() => setTargetPage((p) => p + 1)}
<Button
size="sm"
variant="ghost"
isDisabled={targetPage >= pageCount(Number(targetsQuery.data?.total || 0), TARGET_PAGE_SIZE)}
onPress={() => setTargetPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</Button>
</div>
</div>
) : null}
@ -791,7 +816,7 @@ export function LldpLinksPage() {
<h3>{t("lldpLinks.edgesTitle")}</h3>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<select
<FieldSelect
value={edgeStatus}
onChange={(e) => {
const v = e.target.value;
@ -802,8 +827,8 @@ export function LldpLinksPage() {
<option value="all">{t("lldpLinks.edgeStatusAll")}</option>
<option value="active">{t("lldpLinks.edgeStatusActive")}</option>
<option value="missing">{t("lldpLinks.edgeStatusMissing")}</option>
</select>
<select
</FieldSelect>
<FieldSelect
value={edgeSource}
onChange={(e) => {
setEdgeSource(e.target.value);
@ -813,8 +838,8 @@ export function LldpLinksPage() {
<option value="">{t("lldpLinks.edgeSourceAll")}</option>
<option value="lldp">{t("lldpLinks.edgeSource.lldp")}</option>
<option value="manual">{t("lldpLinks.edgeSource.manual")}</option>
</select>
<input
</FieldSelect>
<Input
value={edgeKeyword}
placeholder={t("lldpLinks.edgeKeywordPh")}
onChange={(e) => {
@ -822,13 +847,14 @@ export function LldpLinksPage() {
setEdgePage(1);
}}
/>
<button
type="button"
disabled={exportingEdges || edgeTotal === 0}
onClick={() => void exportEdgesCsv()}
<Button
size="sm"
variant="secondary"
isDisabled={exportingEdges || edgeTotal === 0}
onPress={() => void exportEdgesCsv()}
>
{exportingEdges ? t("common.exporting") : t("common.exportCsv")}
</button>
</Button>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
@ -893,7 +919,7 @@ export function LldpLinksPage() {
pages={edgePages}
total={edgeTotal}
pageSize={edgePageSize}
pageSizeOptions={[20, 50, 100, 200]}
pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setEdgePage}
onPageSizeChange={(size) => {
setEdgePageSize(size);
@ -904,7 +930,7 @@ export function LldpLinksPage() {
<h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<select
<FieldSelect
value={jobStatus}
onChange={(e) => {
setJobStatus(e.target.value);
@ -919,8 +945,8 @@ export function LldpLinksPage() {
<option value="failed">{t("lldpLinks.jobStatus.failed")}</option>
<option value="cancelled">{t("lldpLinks.jobStatus.cancelled")}</option>
<option value="stopped">{t("lldpLinks.jobStatus.stopped")}</option>
</select>
<input
</FieldSelect>
<Input
value={jobKeyword}
placeholder={t("lldpLinks.jobKeywordPh")}
onChange={(e) => {
@ -928,13 +954,14 @@ export function LldpLinksPage() {
setJobPage(1);
}}
/>
<button
type="button"
disabled={exportingJobs || jobTotal === 0}
onClick={() => void exportJobsCsv()}
<Button
size="sm"
variant="secondary"
isDisabled={exportingJobs || jobTotal === 0}
onPress={() => void exportJobsCsv()}
>
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
</button>
</Button>
</div>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
@ -958,10 +985,10 @@ export function LldpLinksPage() {
return (
<tr key={job.id}>
<td>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => {
<Button
size="sm"
variant="ghost"
onPress={() => {
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
@ -970,7 +997,7 @@ export function LldpLinksPage() {
}}
>
{t("lldpLinks.expand")}
</button>
</Button>
</td>
<td className="mono">{job.id.slice(0, 8)}</td>
<td>{lldpTriggerLabel(t, job.trigger_mode)}</td>
@ -1005,33 +1032,36 @@ export function LldpLinksPage() {
<td>
<div className="btn-row">
{job.status === "running" || job.status === "pending" ? (
<button
type="button"
onClick={() => pauseMut.mutate(job.id)}
disabled={pauseMut.isPending}
<Button
size="sm"
variant="secondary"
isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(job.id)}
>
{t("lldpLinks.pause")}
</button>
</Button>
) : null}
{job.status === "paused" ? (
<button
type="button"
onClick={() => resumeMut.mutate(job.id)}
disabled={resumeMut.isPending}
<Button
size="sm"
variant="secondary"
isDisabled={resumeMut.isPending}
onPress={() => resumeMut.mutate(job.id)}
>
{t("lldpLinks.resume")}
</button>
</Button>
) : null}
{job.status === "running" || job.status === "paused" || job.status === "pending" ? (
<button
type="button"
onClick={() => {
<Button
size="sm"
variant="danger"
isDisabled={stopMut.isPending}
onPress={() => {
if (window.confirm(t("lldpLinks.confirmStop"))) stopMut.mutate(job.id);
}}
disabled={stopMut.isPending}
>
{t("lldpLinks.stop")}
</button>
</Button>
) : null}
</div>
</td>
@ -1062,265 +1092,238 @@ export function LldpLinksPage() {
disabled={jobsQuery.isLoading}
/>
{expandedJobId ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => {
setExpandedJobId("");
setItemDetail(null);
}}
>
<div
className="modal modal--wide ops-detail-modal"
role="dialog"
aria-modal="true"
aria-label={t("lldpLinks.jobDetailTitle")}
onClick={(e) => e.stopPropagation()}
>
<div className="ops-detail-modal__head">
<div className="ops-detail-modal__title">
<h3>{t("lldpLinks.jobDetailTitle")}</h3>
<p className="muted">
{(() => {
const job = jobDetailQuery.data;
if (job) {
return `${job.id.slice(0, 8)} · ${lldpJobStatusLabel(t, job.status)} · ${job.done}/${job.total}`;
}
if (jobDetailQuery.isLoading) {
return `${expandedJobId.slice(0, 8)} · …`;
}
return expandedJobId.slice(0, 8);
})()}
</p>
</div>
<div className="btn-row ops-detail-modal__actions">
<button
type="button"
disabled={exportingItems || itemTotal === 0}
onClick={() => void exportItemsCsv()}
>
{exportingItems ? t("common.exporting") : t("common.exportCsv")}
</button>
<button
type="button"
onClick={() => {
setExpandedJobId("");
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
setItemPage(1);
}}
>
{t("networkConfigs.close")}
</button>
</div>
</div>
<AppModalShell
open={Boolean(expandedJobId)}
onClose={() => {
setExpandedJobId("");
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
setItemPage(1);
}}
size="lg"
>
<Modal.Header>
<Modal.Heading>{t("lldpLinks.jobDetailTitle")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted">
{(() => {
const job = jobDetailQuery.data;
if (job) {
return `${job.id.slice(0, 8)} · ${lldpJobStatusLabel(t, job.status)} · ${job.done}/${job.total}`;
}
if (jobDetailQuery.isLoading) {
return `${expandedJobId.slice(0, 8)} · …`;
}
return expandedJobId.slice(0, 8);
})()}
</p>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<select
value={itemStatus}
onChange={(e) => {
setItemStatus(e.target.value);
setItemPage(1);
}}
>
<option value="">{t("lldpLinks.itemResultAll")}</option>
<option value="ok">{t("lldpLinks.itemResult.ok")}</option>
<option value="warn">{t("lldpLinks.itemResult.warn")}</option>
<option value="fail">{t("lldpLinks.itemResult.fail")}</option>
</select>
<input
value={itemKeyword}
placeholder={t("lldpLinks.itemKeywordPh")}
onChange={(e) => {
setItemKeyword(e.target.value);
setItemPage(1);
}}
/>
</div>
{jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{jobDetailQuery.data?.error ? (
<p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
) : null}
<div className="ops-detail-modal__scroll">
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("lldpLinks.col.name")}</th>
<th>IP</th>
<th>{t("lldpLinks.col.status")}</th>
<th>{t("lldpLinks.col.neighbors")}</th>
<th>{t("lldpLinks.col.edges")}</th>
<th>{t("lldpLinks.col.error")}</th>
<th />
</tr>
</thead>
<tbody>
{detailItems.map((it) => {
const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0);
return (
<tr key={it.id}>
<td>{it.ne_name || it.ne_id || "—"}</td>
<td>{it.ne_ip || "—"}</td>
<td>
{lldpItemResultLabel(t, it)}
</td>
<td>{it.neighbors}</td>
<td>
+{it.edges_added} / ~{it.edges_updated}
{unmatchedCount > 0
? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
: ""}
</td>
<td className="muted">{it.error || "—"}</td>
<td>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setItemDetail(it)}
>
{t("topology.discoverViewDetail")}
</button>
</td>
</tr>
);
})}
{!detailItems.length && !jobDetailQuery.isLoading ? (
<tr>
<td colSpan={7} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
<div className="ops-detail-modal__foot">
<ListPager
page={itemPage}
pages={itemPages}
total={itemTotal}
pageSize={itemPageSize}
pageSizeOptions={[20, 50, 100]}
onPageChange={setItemPage}
onPageSizeChange={(size) => {
setItemPageSize(size);
setItemPage(1);
}}
disabled={jobDetailQuery.isLoading}
/>
</div>
<div className="filter-inline">
<FieldSelect
value={itemStatus}
onChange={(e) => {
setItemStatus(e.target.value);
setItemPage(1);
}}
>
<option value="">{t("lldpLinks.itemResultAll")}</option>
<option value="ok">{t("lldpLinks.itemResult.ok")}</option>
<option value="warn">{t("lldpLinks.itemResult.warn")}</option>
<option value="fail">{t("lldpLinks.itemResult.fail")}</option>
</FieldSelect>
<Input
value={itemKeyword}
placeholder={t("lldpLinks.itemKeywordPh")}
onChange={(e) => {
setItemKeyword(e.target.value);
setItemPage(1);
}}
/>
</div>
</div>
) : null}
{itemDetail ? (
<div className="modal-backdrop" role="presentation" onClick={() => setItemDetail(null)}>
<div
className="modal modal--wide topo-discover-modal"
role="dialog"
aria-modal="true"
aria-labelledby="lldp-item-detail-title"
onClick={(e) => e.stopPropagation()}
>
<div className="topo-discover-modal__head">
<div>
<h3 id="lldp-item-detail-title">
{itemDetail.ne_name || itemDetail.ne_ip || itemDetail.ne_id}
</h3>
<p className="topo-discover-modal__sub">
{[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}
</p>
</div>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setItemDetail(null)}
>
{t("topology.discoverClose")}
</button>
</div>
{jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{jobDetailQuery.data?.error ? (
<p className="ops-detail-modal__error">{jobDetailQuery.data.error}</p>
) : null}
{!itemDetail.ok ? (
<p className="topo-discover__error">
{itemDetail.error === "vendor_or_device_type_required"
? t("topology.discoverVendorRequired")
: itemDetail.error || t("topology.discoverNeFail")}
</p>
) : null}
{itemDetail.parser_stub ? (
<p className="topo-discover__item-warn">
{t("topology.discoverParserStub").replace(
"{{parser}}",
itemDetail.parser_key || "unknown",
)}
</p>
) : null}
<div className="topo-discover-modal__stats">
<span>
{t("topology.discoverNeOk")
.replace("{{neighbors}}", String(itemDetail.neighbors || 0))
.replace("{{added}}", String(itemDetail.edges_added || 0))
.replace("{{updated}}", String(itemDetail.edges_updated || 0))}
</span>
</div>
<h4 className="topo-discover-modal__section">
{t("topology.discoverUnmatchedTitle").replace(
"{{count}}",
String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0),
)}
</h4>
{(itemDetail.unmatched || []).length === 0 ? (
<p className="panel__hint">{t("topology.discoverUnmatchedEmpty")}</p>
) : (
<div className="topo-discover-modal__table-wrap">
<table className="topo-discover-modal__table">
<thead>
<tr>
<th>{t("topology.discoverColRemote")}</th>
<th>{t("topology.discoverColLocalPort")}</th>
<th>{t("topology.discoverColRemotePort")}</th>
</tr>
</thead>
<tbody>
{(itemDetail.unmatched || []).map((u, idx) => (
<tr key={`${itemDetail.id}-u-${idx}`}>
<div className="ops-detail-modal__scroll">
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("lldpLinks.col.name")}</th>
<th>IP</th>
<th>{t("lldpLinks.col.status")}</th>
<th>{t("lldpLinks.col.neighbors")}</th>
<th>{t("lldpLinks.col.edges")}</th>
<th>{t("lldpLinks.col.error")}</th>
<th />
</tr>
</thead>
<tbody>
{detailItems.map((it) => {
const unmatchedCount = it.unmatched_count ?? (it.unmatched?.length || 0);
return (
<tr key={it.id}>
<td>{it.ne_name || it.ne_id || "—"}</td>
<td>{it.ne_ip || "—"}</td>
<td>{lldpItemResultLabel(t, it)}</td>
<td>{it.neighbors}</td>
<td>
{(u.remote_name || u.remote_ip || "?").trim()}
{u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""}
+{it.edges_added} / ~{it.edges_updated}
{unmatchedCount > 0
? ` · ${t("topology.discoverUnmatched").replace("{{count}}", String(unmatchedCount))}`
: ""}
</td>
<td className="muted">{it.error || "—"}</td>
<td>
<Button size="sm" variant="tertiary" onPress={() => setItemDetail(it)}>
{t("topology.discoverViewDetail")}
</Button>
</td>
<td>{u.local_port || "?"}</td>
<td>{u.remote_port || "?"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{itemDetail.raw_preview ? (
<>
<h4 className="topo-discover-modal__section">{t("topology.discoverRawPreview")}</h4>
<pre className="topo-discover-modal__raw">{itemDetail.raw_preview}</pre>
</>
) : null}
<div className="modal__actions">
<button type="button" className="btn btn--sm" onClick={() => setItemDetail(null)}>
{t("topology.discoverClose")}
</button>
);
})}
{!detailItems.length && !jobDetailQuery.isLoading ? (
<tr>
<td colSpan={7} className="muted">
{t("common.empty")}
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</div>
</div>
) : null}
<ListPager
page={itemPage}
pages={itemPages}
total={itemTotal}
pageSize={itemPageSize}
pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setItemPage}
onPageSizeChange={(size) => {
setItemPageSize(size);
setItemPage(1);
}}
disabled={jobDetailQuery.isLoading}
/>
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
isDisabled={exportingItems || itemTotal === 0}
onPress={() => void exportItemsCsv()}
>
{exportingItems ? t("common.exporting") : t("common.exportCsv")}
</Button>
<Button
variant="tertiary"
onPress={() => {
setExpandedJobId("");
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
setItemPage(1);
}}
>
{t("networkConfigs.close")}
</Button>
</Modal.Footer>
</AppModalShell>
<AppModalShell open={Boolean(itemDetail)} onClose={() => setItemDetail(null)} size="lg">
<Modal.Header>
<Modal.Heading>
{itemDetail?.ne_name || itemDetail?.ne_ip || itemDetail?.ne_id}
</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{itemDetail ? (
<>
<p className="topo-discover-modal__sub">
{[itemDetail.ne_ip, itemDetail.command].filter(Boolean).join(SEP)}
</p>
{!itemDetail.ok ? (
<p className="topo-discover__error">
{itemDetail.error === "vendor_or_device_type_required"
? t("topology.discoverVendorRequired")
: itemDetail.error || t("topology.discoverNeFail")}
</p>
) : null}
{itemDetail.parser_stub ? (
<p className="topo-discover__item-warn">
{t("topology.discoverParserStub").replace(
"{{parser}}",
itemDetail.parser_key || "unknown",
)}
</p>
) : null}
<div className="topo-discover-modal__stats">
<span>
{t("topology.discoverNeOk")
.replace("{{neighbors}}", String(itemDetail.neighbors || 0))
.replace("{{added}}", String(itemDetail.edges_added || 0))
.replace("{{updated}}", String(itemDetail.edges_updated || 0))}
</span>
</div>
<h4 className="topo-discover-modal__section">
{t("topology.discoverUnmatchedTitle").replace(
"{{count}}",
String(itemDetail.unmatched_count ?? itemDetail.unmatched?.length ?? 0),
)}
</h4>
{(itemDetail.unmatched || []).length === 0 ? (
<p className="panel__hint">{t("topology.discoverUnmatchedEmpty")}</p>
) : (
<div className="topo-discover-modal__table-wrap">
<table className="topo-discover-modal__table">
<thead>
<tr>
<th>{t("topology.discoverColRemote")}</th>
<th>{t("topology.discoverColLocalPort")}</th>
<th>{t("topology.discoverColRemotePort")}</th>
</tr>
</thead>
<tbody>
{(itemDetail.unmatched || []).map((u, idx) => (
<tr key={`${itemDetail.id}-u-${idx}`}>
<td>
{(u.remote_name || u.remote_ip || "?").trim()}
{u.remote_ip && u.remote_name ? ` (${u.remote_ip})` : ""}
</td>
<td>{u.local_port || "?"}</td>
<td>{u.remote_port || "?"}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{itemDetail.raw_preview ? (
<>
<h4 className="topo-discover-modal__section">{t("topology.discoverRawPreview")}</h4>
<pre className="topo-discover-modal__raw">{itemDetail.raw_preview}</pre>
</>
) : null}
</>
) : null}
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" onPress={() => setItemDetail(null)}>
{t("topology.discoverClose")}
</Button>
</Modal.Footer>
</AppModalShell>
</section>
);
}

View file

@ -1,7 +1,9 @@
import { Button, Input } from "@heroui/react";
import { useEffect, useState } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { ListPager } from "../../components/ListPager";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
@ -32,7 +34,7 @@ export function NetworkAlarmsPage() {
const [curHostName, setCurHostName] = useState("");
const [curKeyword, setCurKeyword] = useState("");
const [curPage, setCurPage] = useState(1);
const [curPageSize, setCurPageSize] = useState(50);
const [curPageSize, setCurPageSize] = useState(10);
const [exporting, setExporting] = useState(false);
// Deep-link from topology (and similar): ?host=&cleared=&severity=&keyword=
@ -135,15 +137,20 @@ export function NetworkAlarmsPage() {
<div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2>
<div className="btn-row">
<button type="button" disabled={exporting || curTotal === 0} onClick={() => void exportCsv()}>
<Button
size="sm"
variant="secondary"
isDisabled={exporting || curTotal === 0}
onPress={() => void exportCsv()}
>
{exporting ? t("common.exporting") : t("common.exportCsv")}
</button>
</Button>
</div>
</div>
<div className="pt-list">
<div className="filter-inline">
<input
<Input
value={curKeyword}
placeholder={t("ume.alarms.keywordPh")}
onChange={(e) => {
@ -151,7 +158,7 @@ export function NetworkAlarmsPage() {
setCurPage(1);
}}
/>
<input
<Input
value={curHostName}
placeholder={t("ume.alarms.hostNamePh")}
onChange={(e) => {
@ -159,7 +166,7 @@ export function NetworkAlarmsPage() {
setCurPage(1);
}}
/>
<select
<FieldSelect
value={curSeverity}
onChange={(e) => {
setCurSeverity(e.target.value);
@ -172,8 +179,8 @@ export function NetworkAlarmsPage() {
<option value="minor">minor</option>
<option value="warning">warning</option>
<option value="info">info</option>
</select>
<select
</FieldSelect>
<FieldSelect
value={curCleared}
onChange={(e) => {
setCurCleared(e.target.value);
@ -183,21 +190,22 @@ export function NetworkAlarmsPage() {
<option value="">{t("ume.alarms.clearedAll")}</option>
<option value="false">{t("ume.alarms.clearedNo")}</option>
<option value="true">{t("ume.alarms.clearedYes")}</option>
</select>
<button
type="button"
title={t("ume.alarms.clearTitle")}
onClick={() => {
</FieldSelect>
<Button
size="sm"
variant="tertiary"
aria-label={t("ume.alarms.clearTitle")}
onPress={() => {
setCurKeyword("");
setCurHostName("");
setCurSeverity("");
setCurCleared("");
setCurPage(1);
}}
disabled={!hasFilters}
isDisabled={!hasFilters}
>
{t("common.clearFilters")}
</button>
</Button>
</div>
{currentQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -232,18 +240,19 @@ export function NetworkAlarmsPage() {
<td className="pt-list-num">{x.ne_id}</td>
<td>
{(x.host_name || "").trim() ? (
<button
<Button
size="sm"
variant="ghost"
className="link-btn pt-list-task-name"
type="button"
onClick={() => {
onPress={() => {
setCurHostName(x.host_name || "");
setCurKeyword("");
setCurPage(1);
}}
title={t("ume.alarms.filterByHost")}
aria-label={t("ume.alarms.filterByHost")}
>
{x.host_name}
</button>
</Button>
) : (
<span className="muted" title={t("ume.alarms.noHostName")}>
{t("common.empty")}
@ -264,7 +273,7 @@ export function NetworkAlarmsPage() {
pages={curPages}
total={curTotal}
pageSize={curPageSize}
pageSizeOptions={[50, 100, 200, 500]}
pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setCurPage}
onPageSizeChange={(size) => {
setCurPageSize(size);

View file

@ -1,7 +1,10 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
@ -30,7 +33,7 @@ export function NetworkConfigsPage() {
const { showOk, showError } = useToast();
const [searchParams, setSearchParams] = useSearchParams();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState(() => String(searchParams.get("q") || ""));
const [source, setSource] = useState("");
const [vendor, setVendor] = useState("");
@ -150,19 +153,20 @@ export function NetworkConfigsPage() {
<div className="panel__toolbar">
<h2>{t("networkConfigs.title")}</h2>
<div className="btn-row">
<button
type="button"
disabled={exportingList || total === 0}
onClick={() => void exportListCsv()}
<Button
size="sm"
variant="secondary"
isDisabled={exportingList || total === 0}
onPress={() => void exportListCsv()}
>
{exportingList ? t("common.exporting") : t("common.exportCsv")}
</button>
</Button>
</div>
</div>
<div className="pt-list">
<div className="filter-inline">
<input
<Input
value={keyword}
placeholder={t("networkConfigs.keywordPh")}
onChange={(e) => {
@ -170,7 +174,7 @@ export function NetworkConfigsPage() {
setPage(1);
}}
/>
<select
<FieldSelect
value={source}
onChange={(e) => {
setSource(e.target.value);
@ -180,8 +184,8 @@ export function NetworkConfigsPage() {
<option value="">{t("networkConfigs.allSource")}</option>
<option value="managed">managed</option>
<option value="ume">ume</option>
</select>
<input
</FieldSelect>
<Input
value={vendor}
placeholder={t("networkConfigs.vendorPh")}
onChange={(e) => {
@ -189,10 +193,11 @@ export function NetworkConfigsPage() {
setPage(1);
}}
/>
<button
type="button"
disabled={!hasFilters}
onClick={() => {
<Button
size="sm"
variant="tertiary"
isDisabled={!hasFilters}
onPress={() => {
setKeyword("");
setSource("");
setVendor("");
@ -200,7 +205,7 @@ export function NetworkConfigsPage() {
}}
>
{t("common.clearFilters")}
</button>
</Button>
</div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -239,22 +244,24 @@ export function NetworkConfigsPage() {
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<button
type="button"
onClick={() => {
<Button
size="sm"
variant="secondary"
onPress={() => {
setSelected({ source: row.source, id: row.target_id });
setTab("primary");
}}
>
{t("networkConfigs.view")}
</button>
<button
type="button"
disabled={
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={
exporting === exportKey ||
exporting.startsWith(`${row.source}:${row.target_id}:`)
}
onClick={() =>
onPress={() =>
void exportConfig(
row.source,
row.target_id,
@ -263,10 +270,11 @@ export function NetworkConfigsPage() {
}
>
{t("networkConfigs.export")}
</button>
<button
type="button"
onClick={() => {
</Button>
<Button
size="sm"
variant="secondary"
onPress={() => {
const path =
row.source === "ume"
? `/webcrt?ne_id=${encodeURIComponent(row.target_id)}&source=ume`
@ -275,7 +283,7 @@ export function NetworkConfigsPage() {
}}
>
WebCRT
</button>
</Button>
</div>
</td>
</tr>
@ -291,7 +299,7 @@ export function NetworkConfigsPage() {
pages={pages}
total={total}
pageSize={pageSize}
pageSizeOptions={[20, 50, 100]}
pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
@ -301,89 +309,84 @@ export function NetworkConfigsPage() {
/>
</div>
{selected ? (
<div
className="modal-backdrop"
role="presentation"
onClick={() => setSelected(null)}
>
<div
className="modal modal--wide nm-config-modal"
role="dialog"
aria-modal="true"
aria-label={t("networkConfigs.view")}
onClick={(e) => e.stopPropagation()}
>
<div className="nm-config-modal__head">
<div className="nm-config-modal__title">
<h3>{detail?.ne_name || selected.id}</h3>
<p className="muted">
{selected.source} · {detail?.ne_ip || "—"} · {detail?.vendor || "—"}
</p>
</div>
<div className="btn-row nm-config-modal__actions">
<button
type="button"
disabled={Boolean(exporting)}
onClick={() =>
void exportConfig(
selected.source,
selected.id,
showAlt ? (tab === "alt" ? "alt" : "primary") : "primary",
)
}
>
{t("networkConfigs.export")}
</button>
{showAlt ? (
<button
type="button"
disabled={Boolean(exporting)}
onClick={() => void exportConfig(selected.source, selected.id, "both")}
>
{t("networkConfigs.exportBoth")}
</button>
) : null}
<button type="button" onClick={() => setSelected(null)}>
{t("networkConfigs.close")}
</button>
</div>
</div>
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{showAlt ? (
<div className="btn-row nm-config-modal__tabs">
<button
type="button"
className={tab === "primary" ? "btn-primary" : undefined}
onClick={() => setTab("primary")}
>
{t("networkConfigs.tabSet")}
</button>
<button
type="button"
className={tab === "alt" ? "btn-primary" : undefined}
onClick={() => setTab("alt")}
>
{t("networkConfigs.tabHier")}
</button>
</div>
) : null}
<textarea
className="nm-config-modal__body"
readOnly
spellCheck={false}
value={tab === "alt" ? detail?.config_alt_text || "" : detail?.config_text || ""}
/>
<p className="muted nm-config-modal__meta">
SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "}
{fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)}
<AppModalShell
open={Boolean(selected)}
onClose={() => setSelected(null)}
size="lg"
className="nm-config-modal"
>
<Modal.Header>
<Modal.Heading>{detail?.ne_name || selected?.id || t("networkConfigs.view")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{selected ? (
<p className="muted">
{selected.source} · {detail?.ne_ip || "—"} · {detail?.vendor || "—"}
</p>
</div>
</div>
) : null}
) : null}
{detailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{showAlt ? (
<div className="btn-row nm-config-modal__tabs">
<Button
size="sm"
variant={tab === "primary" ? "primary" : "secondary"}
onPress={() => setTab("primary")}
>
{t("networkConfigs.tabSet")}
</Button>
<Button
size="sm"
variant={tab === "alt" ? "primary" : "secondary"}
onPress={() => setTab("alt")}
>
{t("networkConfigs.tabHier")}
</Button>
</div>
) : null}
<textarea
className="nm-config-modal__body"
readOnly
spellCheck={false}
value={tab === "alt" ? detail?.config_alt_text || "" : detail?.config_text || ""}
/>
<p className="muted nm-config-modal__meta">
SHA-256: {tab === "alt" ? detail?.config_alt_sha256 : detail?.config_sha256} ·{" "}
{fmtBytes(tab === "alt" ? detail?.plain_alt_size || 0 : detail?.plain_size || 0)}
</p>
</Modal.Body>
<Modal.Footer>
{selected ? (
<>
<Button
variant="secondary"
isDisabled={Boolean(exporting)}
onPress={() =>
void exportConfig(
selected.source,
selected.id,
showAlt ? (tab === "alt" ? "alt" : "primary") : "primary",
)
}
>
{t("networkConfigs.export")}
</Button>
{showAlt ? (
<Button
variant="secondary"
isDisabled={Boolean(exporting)}
onPress={() => void exportConfig(selected.source, selected.id, "both")}
>
{t("networkConfigs.exportBoth")}
</Button>
) : null}
</>
) : null}
<Button variant="tertiary" onPress={() => setSelected(null)}>
{t("networkConfigs.close")}
</Button>
</Modal.Footer>
</AppModalShell>
</section>
);
}

View file

@ -1,6 +1,8 @@
import { Button, Input } from "@heroui/react";
import { useState } from "react";
import { useQuery } from "@tanstack/react-query";
import { ListPager } from "../../components/ListPager";
import { FieldSelect } from "../../components/ui/FieldSelect";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
@ -36,7 +38,7 @@ export function NetworkDevicesPage() {
const [keyword, setKeyword] = useState("");
const [source, setSource] = useState<"all" | "managed" | "ume">("all");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(50);
const [pageSize, setPageSize] = useState(10);
const [exporting, setExporting] = useState(false);
const debouncedKeyword = useDebouncedValue(keyword, 300);
@ -91,15 +93,20 @@ export function NetworkDevicesPage() {
<div className="panel__toolbar">
<h2>{t("networkDevices.title")}</h2>
<div className="btn-row">
<button type="button" disabled={exporting || total === 0} onClick={() => void exportCsv()}>
<Button
size="sm"
variant="secondary"
isDisabled={exporting || total === 0}
onPress={() => void exportCsv()}
>
{exporting ? t("common.exporting") : t("common.exportCsv")}
</button>
</Button>
</div>
</div>
<div className="pt-list">
<div className="filter-inline">
<input
<Input
value={keyword}
placeholder={t("networkDevices.keywordPh")}
onChange={(e) => {
@ -107,7 +114,7 @@ export function NetworkDevicesPage() {
setPage(1);
}}
/>
<select
<FieldSelect
value={source}
onChange={(e) => {
setSource(e.target.value as "all" | "managed" | "ume");
@ -117,18 +124,19 @@ export function NetworkDevicesPage() {
<option value="all">{t("networkDevices.allSource")}</option>
<option value="managed">managed</option>
<option value="ume">ume</option>
</select>
<button
type="button"
disabled={!hasFilters}
onClick={() => {
</FieldSelect>
<Button
size="sm"
variant="tertiary"
isDisabled={!hasFilters}
onPress={() => {
setKeyword("");
setSource("all");
setPage(1);
}}
>
{t("common.clearFilters")}
</button>
</Button>
</div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -167,12 +175,13 @@ export function NetworkDevicesPage() {
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<button type="button" onClick={() => openWebcrt(row)}>
<Button size="sm" variant="secondary" onPress={() => openWebcrt(row)}>
WebCRT
</button>
<button
type="button"
onClick={() =>
</Button>
<Button
size="sm"
variant="secondary"
onPress={() =>
openNewModuleWindow({
moduleId: "network",
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
@ -180,7 +189,7 @@ export function NetworkDevicesPage() {
}
>
{t("network.nav.configs")}
</button>
</Button>
</div>
</td>
</tr>
@ -195,7 +204,7 @@ export function NetworkDevicesPage() {
pages={pages}
total={total}
pageSize={pageSize}
pageSizeOptions={[20, 50, 100, 200]}
pageSizeOptions={[10, 20, 50, 100, 200]}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
createPortTrafficBoard,
@ -52,90 +53,91 @@ export function PortTrafficBoardListPage() {
<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()}
<Button
size="sm"
variant="primary"
isDisabled={createMut.isPending}
onPress={() => createMut.mutate()}
>
{t("portTraffic.boardCreate")}
</button>
</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
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>
{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>
))}
</tbody>
</table>
</div>
)}
</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="primary"
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>
);

View file

@ -1,3 +1,4 @@
import { Button } from "@heroui/react";
import { useQuery, keepPreviousData } from "@tanstack/react-query";
import { fetchPortTrafficCompare } from "../../services/api";
import { queryKeys } from "../../constants/queryKeys";
@ -96,18 +97,18 @@ export function PortTrafficBoardPanelCell({
{title}
</span>
<div className="btn-row">
<button type="button" onClick={() => onMove?.(-1)}>
<Button size="sm" variant="secondary" isIconOnly onPress={() => onMove?.(-1)}>
↑
</button>
<button type="button" onClick={() => onMove?.(1)}>
</Button>
<Button size="sm" variant="secondary" isIconOnly onPress={() => onMove?.(1)}>
↓
</button>
<button type="button" onClick={() => onEdit?.()}>
</Button>
<Button size="sm" variant="secondary" onPress={() => onEdit?.()}>
{t("portTraffic.edit")}
</button>
<button type="button" className="btn--danger" onClick={() => onRemove?.()}>
</Button>
<Button size="sm" variant="danger" onPress={() => onRemove?.()}>
{t("portTraffic.delete")}
</button>
</Button>
</div>
</div>
) : null}
@ -115,9 +116,9 @@ export function PortTrafficBoardPanelCell({
<div className="pt-board-panel__stale">
<p>{t("portTraffic.boardPanelStale")}</p>
{editing ? (
<button type="button" className="btn-primary" onClick={() => onEdit?.()}>
<Button variant="primary" onPress={() => onEdit?.()}>
{t("portTraffic.boardRetarget")}
</button>
</Button>
) : null}
</div>
) : (

View file

@ -1,7 +1,10 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useNavigate, useSearchParams } from "react-router-dom";
import { ListPager } from "../../components/ListPager";
import { AppModalShell } from "../../components/ui/AppModalShell";
import { FieldSelect } from "../../components/ui/FieldSelect";
import {
createPortTrafficDevice,
deletePortTrafficDevice,
@ -35,7 +38,7 @@ import { pageCount } from "../../utils/display";
import { formatSystemTime } from "../../utils/time";
const POLL_MS = 5000;
const TARGET_PAGE_SIZE = 20;
const TARGET_PAGE_SIZE = 10;
type ViewMode = "list" | "wizard" | "edit";
@ -128,7 +131,7 @@ export function PortTrafficPage() {
const [view, setView] = useState<ViewMode>("list");
const [listPage, setListPage] = useState(1);
const [listPageSize, setListPageSize] = useState(50);
const [listPageSize, setListPageSize] = useState(10);
const [listKeyword, setListKeyword] = useState("");
const [listStatus, setListStatus] = useState("");
const [exportingDevices, setExportingDevices] = useState(false);
@ -697,30 +700,32 @@ export function PortTrafficPage() {
<h2>{t("portTraffic.title")}</h2>
<div className="btn-row">
{view !== "list" ? (
<button
type="button"
onClick={() => {
<Button
size="sm"
variant="secondary"
onPress={() => {
resetWizard();
setView("list");
}}
>
{t("portTraffic.backList")}
</button>
</Button>
) : (
<>
<button type="button" onClick={() => navigate("/network/tasks/port-traffic/wall")}>
<Button size="sm" variant="secondary" onPress={() => navigate("/network/tasks/port-traffic/wall")}>
{t("portTraffic.wall")}
</button>
<button
type="button"
disabled={exportingDevices || listTotal === 0}
onClick={() => void exportDevicesCsv()}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={exportingDevices || listTotal === 0}
onPress={() => void exportDevicesCsv()}
>
{exportingDevices ? t("common.exporting") : t("common.exportCsv")}
</button>
<button type="button" className="btn-primary" onClick={openWizard}>
</Button>
<Button size="sm" variant="primary" onPress={openWizard}>
{t("portTraffic.create")}
</button>
</Button>
</>
)}
</div>
@ -752,7 +757,7 @@ export function PortTrafficPage() {
</div>
<div className="filter-inline">
<input
<Input
value={listKeyword}
placeholder={t("portTraffic.keywordPh")}
onChange={(e) => {
@ -760,7 +765,7 @@ export function PortTrafficPage() {
setListPage(1);
}}
/>
<select
<FieldSelect
value={listStatus}
onChange={(e) => {
setListStatus(e.target.value);
@ -772,18 +777,19 @@ export function PortTrafficPage() {
<option value="paused">{t("portTraffic.statusPaused")}</option>
<option value="stopped">{t("portTraffic.statusStopped")}</option>
<option value="draft">{t("portTraffic.statusDraft")}</option>
</select>
<button
type="button"
disabled={!hasListFilters}
onClick={() => {
</FieldSelect>
<Button
size="sm"
variant="tertiary"
isDisabled={!hasListFilters}
onPress={() => {
setListKeyword("");
setListStatus("");
setListPage(1);
}}
>
{t("common.clearFilters")}
</button>
</Button>
</div>
{devicesQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
@ -793,9 +799,9 @@ export function PortTrafficPage() {
<div className="pt-list-empty">
<p>{t("portTraffic.empty")}</p>
{!hasListFilters ? (
<button type="button" className="btn-primary" onClick={openWizard}>
<Button variant="primary" onPress={openWizard}>
{t("portTraffic.create")}
</button>
</Button>
) : null}
</div>
) : devices.length ? (
@ -843,74 +849,70 @@ export function PortTrafficPage() {
</td>
<td>
<div className="pt-list-actions">
<button
type="button"
className="btn-primary"
onClick={() => openWallList()}
>
<Button size="sm" variant="primary" onPress={() => openWallList()}>
{t("portTraffic.wall")}
</button>
<button type="button" onClick={() => openEdit(row)}>
</Button>
<Button size="sm" variant="secondary" onPress={() => openEdit(row)}>
{t("portTraffic.edit")}
</button>
</Button>
{needsNeRebind(row) ? (
<button
type="button"
title={t("portTraffic.rebindHint")}
onClick={() => openRebind(row)}
>
<Button size="sm" variant="secondary" onPress={() => openRebind(row)}>
{t("portTraffic.rebind")}
</button>
</Button>
) : null}
{row.status !== "running" ? (
<button
type="button"
disabled={startMut.isPending}
onClick={() => startMut.mutate(row.id)}
<Button
size="sm"
variant="secondary"
isDisabled={startMut.isPending}
onPress={() => startMut.mutate(row.id)}
>
{t("portTraffic.start")}
</button>
</Button>
) : (
<button
type="button"
disabled={pauseMut.isPending}
onClick={() => pauseMut.mutate(row.id)}
<Button
size="sm"
variant="secondary"
isDisabled={pauseMut.isPending}
onPress={() => pauseMut.mutate(row.id)}
>
{t("portTraffic.pause")}
</button>
</Button>
)}
{row.status !== "stopped" ? (
<button
type="button"
disabled={stopMut.isPending}
onClick={() => stopMut.mutate(row.id)}
<Button
size="sm"
variant="secondary"
isDisabled={stopMut.isPending}
onPress={() => stopMut.mutate(row.id)}
>
{t("portTraffic.stop")}
</button>
</Button>
) : null}
<button
type="button"
<Button
size="sm"
variant="secondary"
className={row.last_error ? "pt-list-log-btn--error" : undefined}
onClick={() => {
onPress={() => {
setLogDeviceId(row.id);
setLogDeviceLabel(deviceLabel(row));
setLogKeyword("");
}}
>
{t("portTraffic.log")}
</button>
<button
type="button"
className="btn--danger"
disabled={deleteMut.isPending}
onClick={() => {
</Button>
<Button
size="sm"
variant="danger"
isDisabled={deleteMut.isPending}
onPress={() => {
if (window.confirm(t("portTraffic.confirmDelete"))) {
deleteMut.mutate(row.id);
}
}}
>
{t("portTraffic.delete")}
</button>
</Button>
</div>
</td>
</tr>
@ -926,7 +928,7 @@ export function PortTrafficPage() {
pages={pages}
total={listTotal}
pageSize={listPageSize}
pageSizeOptions={[20, 50, 100]}
pageSizeOptions={[10, 20, 50, 100]}
onPageChange={setListPage}
onPageSizeChange={(size) => {
setListPageSize(size);
@ -1021,9 +1023,14 @@ export function PortTrafficPage() {
<strong>
{selectedNe.name} ({selectedNe.ip_address})
</strong>
<button type="button" disabled={portsLoading} onClick={() => void discoverSelected()}>
<Button
size="sm"
variant="secondary"
isDisabled={portsLoading}
onPress={() => void discoverSelected()}
>
{portsLoading ? "…" : t("portTraffic.discover")}
</button>
</Button>
{portsError ? <span className="muted">{portsError}</span> : null}
</div>
{discoverMeta ? (
@ -1165,24 +1172,24 @@ export function PortTrafficPage() {
})}
{pickedList.length > 40 ? <li>… +{pickedList.length - 40}</li> : null}
</ul>
<button type="button" className="btn-primary" disabled={createMut.isPending} onClick={submitWizard}>
<Button variant="primary" isDisabled={createMut.isPending} onPress={submitWizard}>
{t("portTraffic.confirmCreate")}
</button>
</Button>
</div>
) : null}
<div className="btn-row" style={{ marginTop: 12 }}>
<button
type="button"
disabled={wizardStep <= 1}
onClick={() => setWizardStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3) : s))}
<Button
variant="secondary"
isDisabled={wizardStep <= 1}
onPress={() => setWizardStep((s) => (s > 1 ? ((s - 1) as 1 | 2 | 3) : s))}
>
{t("portTraffic.prev")}
</button>
<button
type="button"
disabled={wizardStep >= 3}
onClick={() => {
</Button>
<Button
variant="secondary"
isDisabled={wizardStep >= 3}
onPress={() => {
if (wizardStep === 1 && !selectedNe) {
showError(t("portTraffic.pickNeFirst"));
return;
@ -1195,7 +1202,7 @@ export function PortTrafficPage() {
}}
>
{t("portTraffic.next")}
</button>
</Button>
</div>
</div>
) : null}
@ -1228,23 +1235,28 @@ export function PortTrafficPage() {
</label>
<label className="config-sync-policy-field">
{t("portTraffic.concurrency")}
<input
<Input
type="number"
min={1}
max={5}
value={concurrency}
value={String(concurrency)}
onChange={(e) => setConcurrency(Number(e.target.value) || 1)}
/>
</label>
<label className="config-sync-policy-field">
{t("portTraffic.note")}
<input value={note} onChange={(e) => setNote(e.target.value)} />
<Input value={note} onChange={(e) => setNote(e.target.value)} />
</label>
</div>
<div className="btn-row" style={{ margin: "12px 0" }}>
<button type="button" disabled={portsLoading} onClick={() => void discoverSelected()}>
<Button
size="sm"
variant="secondary"
isDisabled={portsLoading}
onPress={() => void discoverSelected()}
>
{portsLoading ? "…" : t("portTraffic.discover")}
</button>
</Button>
<span className="muted">{t("portTraffic.selectedPorts", { count: String(pickedList.length) })}</span>
</div>
{portsError ? <p className="muted">{portsError}</p> : null}
@ -1315,233 +1327,222 @@ export function PortTrafficPage() {
</ul>
)}
<div className="btn-row" style={{ marginTop: 12 }}>
<button
type="button"
className="btn-primary"
disabled={updateMut.isPending || putIfacesMut.isPending}
onClick={() => void saveEdit()}
<Button
variant="primary"
isDisabled={updateMut.isPending || putIfacesMut.isPending}
onPress={() => void saveEdit()}
>
{t("portTraffic.save")}
</button>
</Button>
</div>
</div>
) : null}
{logDeviceId ? (
<div className="modal-backdrop" role="presentation" onClick={closeLog}>
<div
className="modal modal--wide pt-log-modal"
role="dialog"
aria-modal="true"
aria-labelledby="pt-log-title"
onClick={(e) => e.stopPropagation()}
>
<div className="pt-log-modal__head">
<div>
<h3 id="pt-log-title">{t("portTraffic.logTitle")}</h3>
<p className="muted pt-log-modal__sub">
{t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
</p>
</div>
<div className="btn-row">
<button
type="button"
disabled={!filteredLogEvents.length}
onClick={exportEventsCsv}
>
{t("common.exportCsv")}
</button>
<button type="button" onClick={closeLog}>
{t("portTraffic.logClose")}
</button>
</div>
</div>
<div className="filter-inline" style={{ marginBottom: 10 }}>
<input
value={logKeyword}
placeholder={t("portTraffic.keywordPh")}
onChange={(e) => setLogKeyword(e.target.value)}
/>
</div>
{logQuery.isLoading ? (
<p className="muted">…</p>
) : !logEvents.length ? (
<p className="muted">{t("portTraffic.logEmpty")}</p>
) : !filteredLogEvents.length ? (
<p className="muted">{t("portTraffic.logEmpty")}</p>
) : (
<div className="pt-log-modal__list">
{filteredLogEvents.map((ev) => {
const level = String(ev.level || "error").toLowerCase();
const tone =
level === "warn" || level === "warning"
? "warn"
: level === "info"
? "info"
: "error";
return (
<article key={ev.id} className={`pt-log-item pt-log-item--${tone}`}>
<div className="pt-log-item__meta">
<span className="pt-log-item__level">{level}</span>
<span className="pt-log-item__time">
{formatSystemTime(ev.created_at) || "—"}
<AppModalShell open={Boolean(logDeviceId)} onClose={closeLog} size="lg">
<Modal.Header>
<Modal.Heading>{t("portTraffic.logTitle")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
<p className="muted pt-log-modal__sub">
{t("portTraffic.logDevice")}: {logDeviceLabel || "—"}
</p>
<div className="filter-inline">
<Input
value={logKeyword}
placeholder={t("portTraffic.keywordPh")}
onChange={(e) => setLogKeyword(e.target.value)}
/>
</div>
{logQuery.isLoading ? (
<p className="muted">…</p>
) : !logEvents.length ? (
<p className="muted">{t("portTraffic.logEmpty")}</p>
) : !filteredLogEvents.length ? (
<p className="muted">{t("portTraffic.logEmpty")}</p>
) : (
<div className="pt-log-modal__list">
{filteredLogEvents.map((ev) => {
const level = String(ev.level || "error").toLowerCase();
const tone =
level === "warn" || level === "warning"
? "warn"
: level === "info"
? "info"
: "error";
return (
<article key={ev.id} className={`pt-log-item pt-log-item--${tone}`}>
<div className="pt-log-item__meta">
<span className="pt-log-item__level">{level}</span>
<span className="pt-log-item__time">
{formatSystemTime(ev.created_at) || "—"}
</span>
{ev.ifname ? (
<span className="pt-log-item__if" title={ev.ifname}>
{ev.ifname}
</span>
{ev.ifname ? (
<span className="pt-log-item__if" title={ev.ifname}>
{ev.ifname}
</span>
) : null}
</div>
<pre className="pt-log-item__msg">{formatPortTrafficLogMessage(ev.message, t)}</pre>
</article>
);
})}
</div>
)}
</div>
</div>
) : null}
{rebindDevice ? (
<div className="modal-backdrop" role="presentation" onClick={closeRebind}>
<div
className="modal modal--wide pt-rebind-modal"
role="dialog"
aria-modal="true"
aria-labelledby="pt-rebind-title"
onClick={(e) => e.stopPropagation()}
) : null}
</div>
<pre className="pt-log-item__msg">{formatPortTrafficLogMessage(ev.message, t)}</pre>
</article>
);
})}
</div>
)}
</Modal.Body>
<Modal.Footer>
<Button
variant="secondary"
isDisabled={!filteredLogEvents.length}
onPress={exportEventsCsv}
>
<div className="pt-log-modal__head">
<div>
<h3 id="pt-rebind-title">{t("portTraffic.rebindTitle")}</h3>
<p className="muted pt-log-modal__sub">
{t("portTraffic.rebindCurrent")}: {deviceLabel(rebindDevice)}
{rebindDevice.ne_ip ? ` · ${rebindDevice.ne_ip}` : ""}
{` · ${rebindSource}`}
{rebindDevice.ne_id ? ` · ID ${rebindDevice.ne_id.slice(0, 8)}…` : ""}
</p>
<p className="muted pt-log-modal__sub">{t("portTraffic.rebindHint")}</p>
</div>
<button type="button" onClick={closeRebind} disabled={rebindMut.isPending}>
{t("portTraffic.logClose")}
</button>
</div>
{t("common.exportCsv")}
</Button>
<Button variant="tertiary" onPress={closeLog}>
{t("portTraffic.logClose")}
</Button>
</Modal.Footer>
</AppModalShell>
<div className="filter-inline" style={{ marginBottom: 10 }}>
<input
value={rebindKeyword}
onChange={(e) => {
setRebindKeyword(e.target.value);
setRebindPage(1);
}}
placeholder={t("portTraffic.neKeywordPh")}
/>
{rebindSelectedNe ? (
<AppModalShell
open={Boolean(rebindDevice)}
onClose={closeRebind}
dismissible={!rebindMut.isPending}
size="lg"
>
<Modal.Header>
<Modal.Heading>{t("portTraffic.rebindTitle")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">
{rebindDevice ? (
<>
<p className="muted pt-log-modal__sub">
{t("portTraffic.rebindCurrent")}: {deviceLabel(rebindDevice)}
{rebindDevice.ne_ip ? ` · ${rebindDevice.ne_ip}` : ""}
{` · ${rebindSource}`}
{rebindDevice.ne_id ? ` · ID ${rebindDevice.ne_id.slice(0, 8)}…` : ""}
</p>
<p className="muted pt-log-modal__sub">{t("portTraffic.rebindHint")}</p>
<div className="filter-inline">
<Input
value={rebindKeyword}
onChange={(e) => {
setRebindKeyword(e.target.value);
setRebindPage(1);
}}
placeholder={t("portTraffic.neKeywordPh")}
/>
{rebindSelectedNe ? (
<span className="muted">
{t("portTraffic.selectedOneNe")}: {rebindSelectedNe.name} (
{rebindSelectedNe.ip_address})
</span>
) : null}
</div>
{rebindNesQuery.isLoading ? (
<p className="muted">…</p>
) : !(rebindNesQuery.data?.items || []).length ? (
<p className="muted">{t("portTraffic.rebindEmpty")}</p>
) : (
<div className="pt-rebind-modal__table-wrap">
<table className="data-table">
<thead>
<tr>
<th />
<th>{t("portTraffic.col.name")}</th>
<th>IP</th>
<th>{t("portTraffic.col.vendor")}</th>
<th>ID</th>
</tr>
</thead>
<tbody>
{(rebindNesQuery.data?.items || []).map((row) => {
const checked = rebindSelectedNe?.id === row.id;
const sameIp =
Boolean(rebindDevice.ne_ip) &&
String(row.ip_address || "").trim() ===
String(rebindDevice.ne_ip || "").trim();
return (
<tr
key={`${row.source}:${row.id}`}
className={sameIp ? "pt-rebind-row--same-ip" : undefined}
title={sameIp ? t("portTraffic.rebindSameIpHint") : undefined}
>
<td>
<input
type="radio"
name="pt-rebind-ne"
checked={checked}
onChange={() => setRebindSelectedNe(row)}
/>
</td>
<td>{row.name}</td>
<td>
{row.ip_address}
{sameIp ? (
<span className="pt-rebind-same-ip">{t("portTraffic.rebindSameIp")}</span>
) : null}
</td>
<td>{row.vendor || "—"}</td>
<td className="muted" style={{ fontSize: 12 }}>
{row.id.slice(0, 8)}…
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
<div className="pager pt-list-pager">
<Button
size="sm"
variant="ghost"
isIconOnly
className="pager__btn"
isDisabled={rebindPage <= 1}
onPress={() => setRebindPage((p) => p - 1)}
>
‹
</Button>
<span className="muted">
{t("portTraffic.selectedOneNe")}: {rebindSelectedNe.name} (
{rebindSelectedNe.ip_address})
{rebindPage}/
{Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))}
</span>
) : null}
</div>
{rebindNesQuery.isLoading ? (
<p className="muted">…</p>
) : !(rebindNesQuery.data?.items || []).length ? (
<p className="muted">{t("portTraffic.rebindEmpty")}</p>
) : (
<div className="pt-rebind-modal__table-wrap">
<table className="data-table">
<thead>
<tr>
<th />
<th>{t("portTraffic.col.name")}</th>
<th>IP</th>
<th>{t("portTraffic.col.vendor")}</th>
<th>ID</th>
</tr>
</thead>
<tbody>
{(rebindNesQuery.data?.items || []).map((row) => {
const checked = rebindSelectedNe?.id === row.id;
const sameIp =
Boolean(rebindDevice.ne_ip) &&
String(row.ip_address || "").trim() ===
String(rebindDevice.ne_ip || "").trim();
return (
<tr
key={`${row.source}:${row.id}`}
className={sameIp ? "pt-rebind-row--same-ip" : undefined}
title={sameIp ? t("portTraffic.rebindSameIpHint") : undefined}
>
<td>
<input
type="radio"
name="pt-rebind-ne"
checked={checked}
onChange={() => setRebindSelectedNe(row)}
/>
</td>
<td>{row.name}</td>
<td>
{row.ip_address}
{sameIp ? (
<span className="pt-rebind-same-ip">{t("portTraffic.rebindSameIp")}</span>
) : null}
</td>
<td>{row.vendor || "—"}</td>
<td className="muted" style={{ fontSize: 12 }}>
{row.id.slice(0, 8)}…
</td>
</tr>
);
})}
</tbody>
</table>
<Button
size="sm"
variant="ghost"
isIconOnly
className="pager__btn"
isDisabled={
rebindPage >=
Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))
}
onPress={() => setRebindPage((p) => p + 1)}
>
›
</Button>
</div>
)}
<div className="pager pt-list-pager" style={{ marginTop: 8 }}>
<button
type="button"
className="pager__btn"
disabled={rebindPage <= 1}
onClick={() => setRebindPage((p) => p - 1)}
>
‹
</button>
<span className="muted">
{rebindPage}/
{Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))}
</span>
<button
type="button"
className="pager__btn"
disabled={
rebindPage >=
Math.max(1, pageCount(rebindNesQuery.data?.total || 0, TARGET_PAGE_SIZE))
}
onClick={() => setRebindPage((p) => p + 1)}
>
›
</button>
</div>
<div className="modal__actions" style={{ marginTop: 12 }}>
<button type="button" onClick={closeRebind} disabled={rebindMut.isPending}>
{t("portTraffic.boardCancel")}
</button>
<button
type="button"
className="btn-primary"
disabled={!rebindSelectedNe || rebindMut.isPending}
onClick={confirmRebind}
>
{rebindMut.isPending ? "…" : t("portTraffic.rebindSubmit")}
</button>
</div>
</div>
</div>
) : null}
</>
) : null}
</Modal.Body>
<Modal.Footer>
<Button variant="tertiary" isDisabled={rebindMut.isPending} onPress={closeRebind}>
{t("portTraffic.boardCancel")}
</Button>
<Button
variant="primary"
isDisabled={!rebindSelectedNe || rebindMut.isPending}
onPress={confirmRebind}
>
{rebindMut.isPending ? "…" : t("portTraffic.rebindSubmit")}
</Button>
</Modal.Footer>
</AppModalShell>
</section>
);
}

View file

@ -1,3 +1,4 @@
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";
@ -9,6 +10,8 @@ import {
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";
@ -318,7 +321,7 @@ export function PortTrafficWallPage() {
<>
<label className="pt-board-toolbar__field">
{t("portTraffic.boardName")}
<input
<Input
value={draftName}
onChange={(e) => {
setDraftName(e.target.value);
@ -326,21 +329,20 @@ export function PortTrafficWallPage() {
}}
/>
</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);
}}
>
<option value={1}>1</option>
<option value={2}>2</option>
<option value={3}>3</option>
<option value={4}>4</option>
</select>
</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">
@ -351,47 +353,45 @@ export function PortTrafficWallPage() {
</div>
<div className="btn-row pt-board-toolbar__actions">
{panels.length && !editing ? (
<button
type="button"
<Button
size="sm"
variant="secondary"
className="pt-wall-page__fs-btn pt-wall-page__fs-btn--toolbar"
onClick={() => void toggleFullscreen()}
title={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
onPress={() => void toggleFullscreen()}
aria-label={fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}
>
<FullscreenIcon />
<span>{fullscreen ? t("portTraffic.exitFullscreen") : t("portTraffic.fullscreen")}</span>
</button>
</Button>
) : null}
{!editing ? (
<button type="button" className="btn-primary" onClick={startEdit} disabled={!board}>
<Button variant="primary" onPress={startEdit} isDisabled={!board}>
{t("portTraffic.boardEdit")}
</button>
</Button>
) : (
<>
<button
type="button"
className="btn-primary"
disabled={savePanelsMut.isPending}
onClick={() => savePanelsMut.mutate()}
<Button
variant="primary"
isDisabled={savePanelsMut.isPending}
onPress={() => savePanelsMut.mutate()}
>
{t("portTraffic.boardSave")}
</button>
<button type="button" onClick={cancelEdit}>
</Button>
<Button variant="tertiary" onPress={cancelEdit}>
{t("portTraffic.boardCancel")}
</button>
</Button>
</>
)}
{!editing ? (
<button
type="button"
className="btn--danger"
disabled={deleteMut.isPending || !board}
onClick={() => {
<Button
variant="danger"
isDisabled={deleteMut.isPending || !board}
onPress={() => {
if (window.confirm(t("portTraffic.boardConfirmDelete"))) deleteMut.mutate();
}}
>
{t("portTraffic.delete")}
</button>
</Button>
) : null}
</div>
</div>
@ -436,9 +436,9 @@ export function PortTrafficWallPage() {
))}
</select>
</label>
<button type="button" className="btn-primary" onClick={addPanel}>
<Button variant="primary" onPress={addPanel}>
{t("portTraffic.boardAddPanel")}
</button>
</Button>
</div>
) : null}
@ -446,9 +446,9 @@ export function PortTrafficWallPage() {
<div className="pt-wall-shell__empty">
<p>{t("portTraffic.boardNoPanels")}</p>
{!editing ? (
<button type="button" className="btn-primary" onClick={startEdit}>
<Button variant="primary" onPress={startEdit}>
{t("portTraffic.boardEdit")}
</button>
</Button>
) : null}
</div>
) : (
@ -499,25 +499,23 @@ export function PortTrafficWallPage() {
)}
{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 }}>
<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
<Input
value={editPanel.title}
onChange={(e) => updatePanel(editPanel.id, { title: e.target.value })}
/>
</label>
<label>
{t("portTraffic.boardPickDevice")}
<select
<FieldSelect
value={editTargetsDeviceId}
onChange={(e) => {
setEditPickDeviceId(e.target.value);
@ -530,11 +528,11 @@ export function PortTrafficWallPage() {
{d.ne_name || d.ne_ip || d.id}
</option>
))}
</select>
</FieldSelect>
</label>
<label>
{t("portTraffic.wallPort")}
<select
<FieldSelect
value={editPanel.target_id}
disabled={!editTargetsDeviceId}
onChange={(e) => {
@ -558,12 +556,12 @@ export function PortTrafficWallPage() {
{tgt.ifname}
</option>
))}
</select>
</FieldSelect>
</label>
<label>
{t("portTraffic.range")}
<select
value={editPanel.range_hours}
<FieldSelect
value={String(editPanel.range_hours)}
onChange={(e) =>
updatePanel(editPanel.id, { range_hours: Number(e.target.value) || 24 })
}
@ -571,11 +569,11 @@ export function PortTrafficWallPage() {
<option value={1}>1h</option>
<option value={6}>6h</option>
<option value={24}>24h</option>
</select>
</FieldSelect>
</label>
<label>
{t("portTraffic.compare")}
<select
<FieldSelect
value={editPanel.baseline}
onChange={(e) => {
const next = e.target.value as BaselineMode;
@ -592,16 +590,16 @@ export function PortTrafficWallPage() {
<option value="week">{t("portTraffic.compareWeek")}</option>
<option value="shift">{t("portTraffic.compareShift")}</option>
<option value="custom">{t("portTraffic.compareCustom")}</option>
</select>
</FieldSelect>
</label>
{editPanel.baseline === "custom" ? (
<label>
{t("portTraffic.offsetHours")}
<input
<Input
type="number"
min={1}
max={24 * 90}
value={editPanel.offset_hours || 48}
value={String(editPanel.offset_hours || 48)}
onChange={(e) =>
updatePanel(editPanel.id, {
offset_hours: Number(e.target.value) || 24,
@ -612,7 +610,7 @@ export function PortTrafficWallPage() {
) : null}
<label>
{t("portTraffic.yMode")}
<select
<FieldSelect
value={editPanel.y_mode}
onChange={(e) =>
updatePanel(editPanel.id, { y_mode: e.target.value as WallYMode })
@ -621,12 +619,12 @@ export function PortTrafficWallPage() {
<option value="auto">{t("portTraffic.yModeAuto")}</option>
<option value="current">{t("portTraffic.yModeCurrent")}</option>
<option value="util">{t("portTraffic.yModeUtil")}</option>
</select>
</FieldSelect>
</label>
<label>
{t("portTraffic.boardColSpan")}
<select
value={editPanel.col_span || 1}
<FieldSelect
value={String(editPanel.col_span || 1)}
onChange={(e) =>
updatePanel(editPanel.id, {
col_span: Math.min(cols, Number(e.target.value) || 1),
@ -638,11 +636,11 @@ export function PortTrafficWallPage() {
{n}
</option>
))}
</select>
</FieldSelect>
</label>
<label>
{t("portTraffic.filterMap")} · {t("portTraffic.wallDevice")}
<select
<FieldSelect
value={editMapBaselineDeviceId}
disabled={!editPanel.target_id}
onChange={(e) => {
@ -659,11 +657,11 @@ export function PortTrafficWallPage() {
{d.ne_name || d.ne_ip || d.id}
</option>
))}
</select>
</FieldSelect>
</label>
<label>
{t("portTraffic.mapBaselinePort")}
<select
<FieldSelect
value={editPanel.baseline_target_id || ""}
disabled={!editMapBaselineDeviceId}
onChange={(e) => {
@ -682,18 +680,18 @@ export function PortTrafficWallPage() {
{tgt.ifname}
</option>
))}
</select>
</FieldSelect>
</label>
{editPanel.baseline !== "off" || editPanel.baseline_target_id ? (
<label>
{t("portTraffic.aheadHours")}
<input
<Input
type="number"
min={0}
max={24}
step={1}
title={t("portTraffic.aheadHoursHint")}
value={editPanel.ahead_hours ?? 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)),
@ -704,22 +702,18 @@ export function PortTrafficWallPage() {
) : null}
</div>
{editPanel.baseline === "week" ? (
<p className="muted" style={{ marginTop: 10 }}>
{t("portTraffic.retentionHint")}
</p>
<p className="muted">{t("portTraffic.retentionHint")}</p>
) : null}
{editPanel.baseline_target_id ? (
<p className="muted" style={{ marginTop: 10 }}>
{t("portTraffic.mapBaselineHint")}
</p>
<p className="muted">{t("portTraffic.mapBaselineHint")}</p>
) : null}
<div className="modal__actions">
<button type="button" className="btn-primary" onClick={closePanelSettings}>
{t("portTraffic.logClose")}
</button>
</div>
</div>
</div>
</Modal.Body>
<Modal.Footer>
<Button variant="primary" onPress={closePanelSettings}>
{t("portTraffic.logClose")}
</Button>
</Modal.Footer>
</AppModalShell>
) : null}
</section>
);

View file

@ -1,3 +1,4 @@
import { Button, Input } from "@heroui/react";
import { useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@ -9,13 +10,15 @@ import {
matchFabricNodes,
patchFabricNodeTags,
} from "../../services/api";
import { ListPager } from "../../components/ListPager";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
import { useToast } from "../../hooks/useToast";
import { openOrFocusModule } from "../../utils/moduleWindows";
import type { FabricNodeSearchHit, TopologyTreeFolderItem } from "../../types";
import { pageCount } from "../../utils/display";
const PAGE_SIZE = 50;
const PAGE_SIZE_OPTIONS = [10, 20, 50, 100];
function flattenRegions(root: TopologyTreeFolderItem | null | undefined): TopologyTreeFolderItem[] {
if (!root) return [];
@ -95,6 +98,7 @@ export function TopologyClassifyPage() {
const [unmatched, setUnmatched] = useState("");
const [linkStatus, setLinkStatus] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const linkLabel = (status: string | undefined) => {
switch (String(status || "").toLowerCase()) {
@ -144,6 +148,7 @@ export function TopologyClassifyPage() {
linkStatus,
page,
filterMajor,
pageSize,
),
queryFn: () =>
fetchFabricNodes({
@ -153,7 +158,7 @@ export function TopologyClassifyPage() {
unmatched,
linkStatus,
page,
pageSize: PAGE_SIZE,
pageSize,
}),
});
@ -246,7 +251,7 @@ export function TopologyClassifyPage() {
.filter((n) => selected[n.id] && isFabricNodeDeletable(n))
.map((n) => n.id);
const total = listQuery.data?.total || 0;
const pages = Math.max(1, Math.ceil(total / PAGE_SIZE));
const pages = pageCount(total, pageSize);
const selectedCount = Object.values(selected).filter(Boolean).length;
const highlight = useMemo(() => new Set(matchedIds), [matchedIds]);
@ -273,10 +278,9 @@ export function TopologyClassifyPage() {
<div className="panel__toolbar">
<h2>{t("topoClassify.title")}</h2>
<div className="btn-row">
<button
type="button"
className="btn-primary"
onClick={() => {
<Button
variant="primary"
onPress={() => {
const regionId = filterRegion || assignRegion || "";
const folder = regions.find((r) => r.id === regionId);
const views = folder?.views || [];
@ -289,15 +293,14 @@ export function TopologyClassifyPage() {
}}
>
{t("topoClassify.openTopo")}
</button>
</Button>
</div>
</div>
<section className="topo-classify__section topo-classify__section--first">
<h3>{t("topoClassify.inventory")}</h3>
<div className="topo-classify__draft">
<input
className="input"
<Input
placeholder={t("topoClassify.keywordPh")}
value={keyword}
onChange={(e) => {
@ -305,8 +308,7 @@ export function TopologyClassifyPage() {
setPage(1);
}}
/>
<select
className="input"
<select className="input"
value={filterMajor}
onChange={(e) => {
setFilterMajor(e.target.value);
@ -319,8 +321,7 @@ export function TopologyClassifyPage() {
</option>
))}
</select>
<select
className="input"
<select className="input"
value={filterRegion}
onChange={(e) => {
setFilterRegion(e.target.value);
@ -334,8 +335,7 @@ export function TopologyClassifyPage() {
</option>
))}
</select>
<select
className="input"
<select className="input"
value={unmatched}
onChange={(e) => {
setUnmatched(e.target.value);
@ -347,8 +347,7 @@ export function TopologyClassifyPage() {
<option value="level">{t("topoClassify.kindLevel")}</option>
<option value="region">{t("topoClassify.kindRegion")}</option>
</select>
<select
className="input"
<select className="input"
value={linkStatus}
onChange={(e) => {
setLinkStatus(e.target.value);
@ -363,14 +362,13 @@ export function TopologyClassifyPage() {
<div className="topo-classify__regex-bar">
<strong>{t("topoClassify.regexTitle")}</strong>
<input
className="input topo-classify__regex-input"
<Input
className="topo-classify__regex-input"
placeholder={t("topoClassify.patternPh")}
value={regex}
onChange={(e) => setRegex(e.target.value)}
/>
<select
className="input"
<select className="input"
value={matchField}
onChange={(e) => setMatchField(e.target.value as "name" | "ip" | "name_ip")}
>
@ -378,14 +376,14 @@ export function TopologyClassifyPage() {
<option value="ip">IP</option>
<option value="name_ip">{t("topoClassify.fieldNameIp")}</option>
</select>
<button
type="button"
className="btn btn--sm"
disabled={!regex.trim() || matchMut.isPending}
onClick={() => matchMut.mutate()}
<Button
size="sm"
variant="secondary"
isDisabled={!regex.trim() || matchMut.isPending}
onPress={() => matchMut.mutate()}
>
{t("topoClassify.findMatches")}
</button>
</Button>
{matchTotal > 0 ? (
<span className="panel__hint">
{t("topoClassify.matchHint").replace("{{count}}", String(matchTotal))}
@ -394,8 +392,7 @@ export function TopologyClassifyPage() {
</div>
<div className="topo-classify__assign-bar">
<select
className="input"
<select className="input"
value={assignWhat}
onChange={(e) => setAssignWhat(e.target.value as "level" | "region" | "both")}
>
@ -404,11 +401,7 @@ export function TopologyClassifyPage() {
<option value="both">{t("topoClassify.assignBoth")}</option>
</select>
{assignWhat !== "region" ? (
<select
className="input"
value={assignLevel}
onChange={(e) => setAssignLevel(e.target.value)}
>
<select className="input" value={assignLevel} onChange={(e) => setAssignLevel(e.target.value)}>
{LEVEL_PRESETS.map((r) => (
<option key={r.value} value={r.value}>
{levelPresetLabel(t, r.value)}
@ -417,11 +410,7 @@ export function TopologyClassifyPage() {
</select>
) : null}
{assignWhat !== "level" ? (
<select
className="input"
value={assignRegion}
onChange={(e) => setAssignRegion(e.target.value)}
>
<select className="input" value={assignRegion} onChange={(e) => setAssignRegion(e.target.value)}>
<option value="">{t("topoClassify.clearRegion")}</option>
{regions.map((r) => (
<option key={r.id} value={r.id}>
@ -430,24 +419,23 @@ export function TopologyClassifyPage() {
))}
</select>
) : null}
<button
type="button"
className="btn btn--sm"
disabled={bulkMut.isPending || (selectedCount === 0 && matchedIds.length === 0)}
onClick={() => {
<Button
size="sm"
variant="secondary"
isDisabled={bulkMut.isPending || (selectedCount === 0 && matchedIds.length === 0)}
onPress={() => {
const n = selectedCount || matchedIds.length;
const msg = t("topoClassify.bulkConfirm").replace("{{count}}", String(n));
if (window.confirm(msg)) bulkMut.mutate();
}}
>
{t("topoClassify.confirmAssign")}
</button>
<button
type="button"
className="btn btn--sm btn--danger"
disabled={deleteBulkMut.isPending || deletableSelected.length === 0}
title={t("topoClassify.deleteHint")}
onClick={() => {
</Button>
<Button
size="sm"
variant="danger"
isDisabled={deleteBulkMut.isPending || deletableSelected.length === 0}
onPress={() => {
const msg = t("topoClassify.deleteConfirm").replace(
"{{count}}",
String(deletableSelected.length),
@ -459,7 +447,7 @@ export function TopologyClassifyPage() {
"{{count}}",
String(deletableSelected.length),
)}
</button>
</Button>
<span className="topo-classify__help" tabIndex={0} aria-label={t("topoClassify.deleteHint")}>
?
<span className="topo-classify__help-tip" role="tooltip">
@ -510,8 +498,7 @@ export function TopologyClassifyPage() {
</td>
<td>
{editing ? (
<input
className="input"
<Input
value={editLevel}
placeholder="1 / 1.1 / 2.1"
onChange={(e) => setEditLevel(e.target.value)}
@ -522,11 +509,7 @@ export function TopologyClassifyPage() {
</td>
<td>
{editing ? (
<select
className="input"
value={editRegion}
onChange={(e) => setEditRegion(e.target.value)}
>
<select className="input" value={editRegion} onChange={(e) => setEditRegion(e.target.value)}>
<option value="">-</option>
{regions.map((r) => (
<option key={r.id} value={r.id}>
@ -543,41 +526,28 @@ export function TopologyClassifyPage() {
<td>
{editing ? (
<div className="topo-classify__row-actions">
<button
type="button"
className="btn btn--sm"
disabled={patchMut.isPending}
onClick={() => patchMut.mutate()}
<Button
size="sm"
variant="secondary"
isDisabled={patchMut.isPending}
onPress={() => patchMut.mutate()}
>
{t("topoClassify.save")}
</button>
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => setEditingId("")}
>
</Button>
<Button size="sm" variant="ghost" onPress={() => setEditingId("")}>
{t("topoClassify.cancel")}
</button>
</Button>
</div>
) : (
<div className="topo-classify__row-actions">
<button
type="button"
className="btn btn--sm btn--ghost"
onClick={() => startEdit(n)}
>
<Button size="sm" variant="ghost" onPress={() => startEdit(n)}>
{t("topoClassify.edit")}
</button>
<button
type="button"
className="btn btn--sm btn--danger"
disabled={deleteOneMut.isPending || !isFabricNodeDeletable(n)}
title={
isFabricNodeDeletable(n)
? t("topoClassify.deleteHint")
: t("topoClassify.deleteBlocked")
}
onClick={() => {
</Button>
<Button
size="sm"
variant="danger"
isDisabled={deleteOneMut.isPending || !isFabricNodeDeletable(n)}
onPress={() => {
if (!isFabricNodeDeletable(n)) return;
const msg = t("topoClassify.deleteConfirm").replace(
"{{count}}",
@ -587,7 +557,7 @@ export function TopologyClassifyPage() {
}}
>
{t("topoClassify.delete")}
</button>
</Button>
</div>
)}
</td>
@ -603,30 +573,19 @@ export function TopologyClassifyPage() {
) : null}
</tbody>
</table>
<div className="topo-classify__pager">
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={page <= 1}
onClick={() => setPage((p) => Math.max(1, p - 1))}
>
{t("common.prevPage")}
</button>
<span>
{t("common.pagerMeta")
.replace("{{total}}", String(total))
.replace("{{page}}", String(page))
.replace("{{pages}}", String(pages))}
</span>
<button
type="button"
className="btn btn--sm btn--ghost"
disabled={page >= pages}
onClick={() => setPage((p) => p + 1)}
>
{t("common.nextPage")}
</button>
</div>
<ListPager
page={page}
pages={pages}
total={total}
pageSize={pageSize}
pageSizeOptions={PAGE_SIZE_OPTIONS}
onPageChange={setPage}
onPageSizeChange={(size) => {
setPageSize(size);
setPage(1);
}}
disabled={listQuery.isLoading}
/>
</section>
</div>
);