mirror of
https://github.com/hansjone/netx.git
synced 2026-10-10 18:03:14 +08:00
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:
parent
5096117ad4
commit
c4526e36d8
64 changed files with 6316 additions and 4521 deletions
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
) : (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue