feat: unify network and topology table column filters

Replace top filter bars with shared Excel-style column menus, fix select-all and modal interactions, and use plain dropdown indicators. Apply exact-value filters before pagination and export. Isolate pytest database state and cover filter behavior.
This commit is contained in:
oliver 2026-10-12 23:36:00 +08:00
parent ed820126df
commit 3c9e0efd2b
32 changed files with 1429 additions and 645 deletions

View file

@ -0,0 +1,251 @@
import { Button, Input } from "@heroui/react";
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { useQuery } from "@tanstack/react-query";
import { useI18n } from "../../i18n";
import type { ColumnValueFilter } from "../../types/tableFilters";
const EMPTY_FILTER_OPTIONS: string[] = [];
const identity = (value: string) => value;
export function ColumnFilterMenu({ label, value, queryKey, loadOptions, onApply, formatValue = identity }: {
label: string;
value: ColumnValueFilter;
queryKey: readonly unknown[];
loadOptions: () => Promise<string[]>;
onApply: (value: ColumnValueFilter) => void;
formatValue?: (value: string) => string;
}) {
const { t } = useI18n();
const placeholder = t("networkDevices.columnFilterPh");
const selectAllLabel = t("networkDevices.selectAllColumnValues");
const applyLabel = t("networkDevices.applyColumnFilter");
const clearLabel = t("networkDevices.clearColumnFilter");
const blankLabel = t("networkDevices.blankFilterValue");
const loadingLabel = t("networkDevices.loadingFilterOptions");
const noOptionsLabel = t("networkDevices.noFilterOptions");
const cancelLabel = t("networkDevices.cancelColumnFilter");
const errorLabel = t("networkDevices.filterOptionsError");
const retryLabel = t("networkDevices.retryFilterOptions");
const moreLabel = t("networkDevices.moreFilterOptions");
const [open, setOpen] = useState(false);
const [draft, setDraft] = useState<ColumnValueFilter>(value);
const [optionSearch, setOptionSearch] = useState("");
const rootRef = useRef<HTMLSpanElement>(null);
const triggerRef = useRef<HTMLButtonElement>(null);
const menuRef = useRef<HTMLDivElement>(null);
const [renderLimit, setRenderLimit] = useState(100);
const [portalContainer, setPortalContainer] = useState<Element | null>(null);
const optionsQuery = useQuery({
queryKey,
queryFn: loadOptions,
enabled: open,
staleTime: 30000,
});
const options = optionsQuery.data ?? EMPTY_FILTER_OPTIONS;
const visibleOptions = useMemo(() => {
const search = optionSearch.trim().toLocaleLowerCase();
return options.filter((option) => formatValue(option).toLocaleLowerCase().includes(search));
}, [options, optionSearch, formatValue]);
const draftValues = new Set(draft?.values);
const selectedVisibleCount = visibleOptions.filter((option) =>
draft === null
? true
: draft.mode === "include"
? draftValues.has(option)
: !draftValues.has(option),
).length;
const allVisibleSelected = visibleOptions.length > 0 && selectedVisibleCount === visibleOptions.length;
const someVisibleSelected = selectedVisibleCount > 0 && !allVisibleSelected;
useLayoutEffect(() => {
if (!open) return;
const positionMenu = () => {
const trigger = triggerRef.current;
const menu = menuRef.current;
if (!trigger || !menu) return;
const rect = trigger.getBoundingClientRect();
const width = menu.offsetWidth;
const height = menu.offsetHeight;
menu.style.left = `${Math.max(8, Math.min(rect.left, window.innerWidth - width - 8))}px`;
const below = rect.bottom + 6;
menu.style.top = `${Math.max(8, below + height <= window.innerHeight - 8 ? below : rect.top - height - 6)}px`;
};
positionMenu();
const observer = new ResizeObserver(positionMenu);
if (menuRef.current) observer.observe(menuRef.current);
window.addEventListener("resize", positionMenu);
document.addEventListener("scroll", positionMenu, true);
return () => {
observer.disconnect();
window.removeEventListener("resize", positionMenu);
document.removeEventListener("scroll", positionMenu, true);
};
}, [open]);
useEffect(() => {
if (!open) return;
const closeOutside = (event: PointerEvent) => {
if (!rootRef.current?.contains(event.target as Node) && !menuRef.current?.contains(event.target as Node)) setOpen(false);
};
const closeOnEscape = (event: KeyboardEvent) => {
if (event.key === "Escape") {
setOpen(false);
triggerRef.current?.focus();
}
};
document.addEventListener("pointerdown", closeOutside);
document.addEventListener("keydown", closeOnEscape);
return () => {
document.removeEventListener("pointerdown", closeOutside);
document.removeEventListener("keydown", closeOnEscape);
};
}, [open]);
const apply = (next: ColumnValueFilter = draft) => {
onApply(next);
setOpen(false);
triggerRef.current?.focus();
};
const toggleOption = (option: string) => {
if (draft === null) {
setDraft({ mode: "exclude", values: [option] });
return;
}
if (draft.mode === "exclude") {
const values = draft.values.includes(option)
? draft.values.filter((item) => item !== option)
: [...draft.values, option];
setDraft(values.length ? { mode: "exclude", values } : null);
return;
}
const values = draft.values.includes(option)
? draft.values.filter((item) => item !== option)
: [...draft.values, option];
setDraft({ mode: "include", values });
};
const isChecked = (option: string) => {
if (draft === null) return true;
return draft.mode === "include"
? draftValues.has(option)
: !draftValues.has(option);
};
const setVisibleSelection = (selected: boolean) => {
if (!optionSearch.trim()) {
setDraft(selected ? null : { mode: "include", values: [] });
return;
}
const visible = new Set(visibleOptions);
if (draft === null) {
if (!selected) setDraft({ mode: "exclude", values: visibleOptions });
return;
}
if (draft.mode === "include") {
const hiddenSelections = draft.values.filter((option) => !visible.has(option));
const values = selected
? [...new Set([...hiddenSelections, ...visibleOptions])]
: hiddenSelections;
setDraft({ mode: "include", values });
return;
}
const excluded = new Set(draft.values);
for (const option of visibleOptions) {
if (selected) excluded.delete(option);
else excluded.add(option);
}
setDraft(excluded.size ? { mode: "exclude", values: [...excluded] } : null);
};
return (
<span className="ne-column-filter" ref={rootRef}>
<button
type="button"
ref={triggerRef}
className={`ne-column-filter__trigger${value ? " is-active" : ""}`}
aria-label={`${label}: ${placeholder}`}
title={`${label}: ${placeholder}`}
aria-expanded={open}
aria-haspopup="dialog"
onClick={() => {
if (!open) {
setPortalContainer(rootRef.current?.closest(".app-heroui-modal__dialog, .network-main") ?? document.body);
setDraft(value);
setOptionSearch("");
setRenderLimit(100);
}
setOpen((current) => !current);
}}
>
▾
</button>
{open && portalContainer ? createPortal(
<div className="ne-column-filter__menu" ref={menuRef} role="dialog" aria-label={label}
onKeyDownCapture={(event) => {
if (event.key === "Escape") {
event.preventDefault();
event.stopPropagation();
setOpen(false);
triggerRef.current?.focus();
}
}}>
<Input
autoFocus
aria-label={placeholder}
placeholder={placeholder}
value={optionSearch}
onChange={(event) => {
setOptionSearch(event.target.value);
setRenderLimit(100);
}}
/>
<label
className="ne-column-filter__select-all"
>
<input
ref={(input) => { if (input) input.indeterminate = someVisibleSelected; }}
type="checkbox"
checked={allVisibleSelected}
disabled={!visibleOptions.length || optionsQuery.isFetching || optionsQuery.isError}
onChange={(event) => setVisibleSelection(event.target.checked)}
/>
<span>{selectAllLabel} ({selectedVisibleCount}/{visibleOptions.length})</span>
</label>
<div className="ne-column-filter__options">
{optionsQuery.isLoading ? <span className="muted">{loadingLabel}</span> : null}
{!optionsQuery.isLoading && optionsQuery.isError ? (
<div className="error-text">
{errorLabel}
<Button size="sm" variant="tertiary" onPress={() => { void optionsQuery.refetch(); }}>{retryLabel}</Button>
</div>
) : null}
{!optionsQuery.isLoading && !optionsQuery.isError && !visibleOptions.length ? (
<span className="muted">{noOptionsLabel}</span>
) : null}
{visibleOptions.slice(0, renderLimit).map((option) => (
<label className="ne-column-filter__option" key={option} title={option ? formatValue(option) : blankLabel}>
<input
type="checkbox"
checked={isChecked(option)}
disabled={optionsQuery.isFetching || optionsQuery.isError}
onChange={() => toggleOption(option)}
/>
<span>{option ? formatValue(option) : blankLabel}</span>
</label>
))}
{visibleOptions.length > renderLimit ? (
<Button size="sm" variant="tertiary" onPress={() => setRenderLimit((limit) => limit + 100)}>{moreLabel}</Button>
) : null}
</div>
<div className="ne-column-filter__actions">
<Button size="sm" variant="tertiary" onPress={() => apply(null)}>{clearLabel}</Button>
<Button size="sm" variant="tertiary" onPress={() => { setOpen(false); triggerRef.current?.focus(); }}>{cancelLabel}</Button>
<Button size="sm" variant="secondary" isDisabled={optionsQuery.isFetching || optionsQuery.isError} onPress={() => apply()}>{applyLabel}</Button>
</div>
</div>,
portalContainer,
) : null}
</span>
);
}

View file

@ -5,9 +5,9 @@ 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 { useTableColumnFilters } from "../../hooks/useTableColumnFilters";
import { useLocalTableColumnFilters } from "../../hooks/useLocalTableColumnFilters";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import {
@ -109,26 +109,16 @@ export function LldpLinksPage() {
const [jobPage, setJobPage] = useState(1);
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(10);
const [itemStatus, setItemStatus] = useState("");
const [itemKeyword, setItemKeyword] = useState("");
const [exportingItems, setExportingItems] = useState(false);
const debouncedItemKeyword = useDebouncedValue(itemKeyword, 300);
const [itemDetail, setItemDetail] = useState<TopologyDiscoverJobItem | null>(null);
const [edgeStatus, setEdgeStatus] = useState<"all" | "active" | "missing">("all");
const [edgeSource, setEdgeSource] = useState("");
const [edgeKeyword, setEdgeKeyword] = useState("");
const [edgePage, setEdgePage] = useState(1);
const [edgePageSize, setEdgePageSize] = useState(10);
const [exportingEdges, setExportingEdges] = useState(false);
const debouncedEdgeKeyword = useDebouncedValue(edgeKeyword, 300);
const [enabled, setEnabled] = useState(false);
const [intervalValue, setIntervalValue] = useState(1);
@ -140,9 +130,16 @@ export function LldpLinksPage() {
const [selectedMap, setSelectedMap] = useState<Record<string, ConfigSyncTargetRef>>({});
const [policyHydrated, setPolicyHydrated] = useState(false);
const [targetKeyword, setTargetKeyword] = useState("");
const [targetPage, setTargetPage] = useState(1);
const edgeColumns = useTableColumnFilters("/v1/topology/fabric/edges", () => setEdgePage(1));
const jobColumns = useTableColumnFilters("/v1/topology/lldp-collect/jobs", () => setJobPage(1));
const itemColumns = useTableColumnFilters(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(expandedJobId)}`, () => setItemPage(1));
const targetColumns = useTableColumnFilters("/v1/cli/targets", () => setTargetPage(1), { source: "all" });
const unmatchedColumns = useLocalTableColumnFilters(itemDetail?.id || "", itemDetail?.unmatched || [], (row) => ({
remote: row.remote_name || row.remote_ip || "", localPort: row.local_port || "", remotePort: row.remote_port || "",
}));
const dashQuery = useQuery({
queryKey: queryKeys.lldpCollectDashboard,
queryFn: fetchLldpCollectDashboard,
@ -156,8 +153,7 @@ export function LldpLinksPage() {
},
});
useEffect(() => {
if (!dashQuery.data || policyHydrated) return;
if (dashQuery.data && !policyHydrated) {
const p = dashQuery.data.policy;
setEnabled(Boolean(p.enabled));
const hours = Math.max(1, Number(p.interval_hours || (Number(p.interval_days || 1) * 24)));
@ -179,84 +175,48 @@ export function LldpLinksPage() {
}
setSelectedMap(map);
setPolicyHydrated(true);
}, [dashQuery.data, policyHydrated]);
}
// Deep link from topology canvas: /network/topology/lldp?job_id=…
useEffect(() => {
const jobId = String(searchParams.get("job_id") || "").trim();
if (!jobId) return;
// Apply each deep-link job once; keep URL cleanup separate from local state.
const linkedJobId = String(searchParams.get("job_id") || "").trim();
const [appliedLinkedJobId, setAppliedLinkedJobId] = useState("");
if (linkedJobId && linkedJobId !== appliedLinkedJobId) {
setAppliedLinkedJobId(linkedJobId);
setItemDetail(null);
setItemPage(1);
setItemStatus("");
setItemKeyword("");
setExpandedJobId(jobId);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (next.get("job_id") === jobId) next.delete("job_id");
return next;
},
{ replace: true },
);
}, [searchParams, setSearchParams]);
setExpandedJobId(linkedJobId);
}
useEffect(() => {
if (!linkedJobId) return;
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
if (next.get("job_id") === linkedJobId) next.delete("job_id");
return next;
}, { replace: true });
}, [linkedJobId, setSearchParams]);
const jobsQuery = useQuery({
queryKey: queryKeys.lldpCollectJobs(jobPage, jobPageSize, jobStatus, debouncedJobKeyword),
queryFn: () =>
fetchLldpCollectJobs({
page: jobPage,
pageSize: jobPageSize,
status: jobStatus,
keyword: debouncedJobKeyword,
}),
queryKey: [...queryKeys.lldpCollectJobs(jobPage, jobPageSize, "", ""), jobColumns.key],
queryFn: () => fetchLldpCollectJobs({ page: jobPage, pageSize: jobPageSize, columnFilters: jobColumns.filters }),
staleTime: 1000,
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
});
const jobDetailQuery = useQuery({
queryKey: queryKeys.lldpCollectJob(
expandedJobId,
itemPage,
itemPageSize,
itemStatus,
debouncedItemKeyword,
),
queryFn: () =>
fetchLldpCollectJob(expandedJobId, {
page: itemPage,
pageSize: itemPageSize,
status: itemStatus,
keyword: debouncedItemKeyword,
}),
queryKey: [...queryKeys.lldpCollectJob(expandedJobId, itemPage, itemPageSize, "", ""), itemColumns.key],
queryFn: () => fetchLldpCollectJob(expandedJobId, { page: itemPage, pageSize: itemPageSize, columnFilters: itemColumns.filters }),
enabled: Boolean(expandedJobId),
staleTime: 800,
refetchInterval: () => (dashQuery.data?.running_job?.id === expandedJobId ? POLL_MS : false),
});
const targetsQuery = useQuery({
queryKey: queryKeys.cliTargets(targetKeyword, targetPage, TARGET_PAGE_SIZE),
queryFn: () =>
fetchCliTargets({ source: "all", keyword: targetKeyword, page: targetPage, pageSize: TARGET_PAGE_SIZE }),
queryKey: queryKeys.cliTargets(`all:${targetColumns.key}`, targetPage, TARGET_PAGE_SIZE),
queryFn: () => fetchCliTargets({ source: "all", columnFilters: targetColumns.filters, page: targetPage, pageSize: TARGET_PAGE_SIZE }),
enabled: scopeMode === "selected",
staleTime: 5000,
});
const edgesQuery = useQuery({
queryKey: queryKeys.fabricEdges(
edgeStatus,
debouncedEdgeKeyword,
edgePage,
edgePageSize,
edgeSource,
),
queryFn: () =>
fetchFabricEdges({
status: edgeStatus === "all" ? "" : edgeStatus,
keyword: debouncedEdgeKeyword,
source: edgeSource || undefined,
page: edgePage,
pageSize: edgePageSize,
}),
queryKey: [...queryKeys.fabricEdges("all", "", edgePage, edgePageSize, ""), edgeColumns.key],
queryFn: () => fetchFabricEdges({ columnFilters: edgeColumns.filters, page: edgePage, pageSize: edgePageSize }),
staleTime: 2000,
refetchInterval: () => (dashQuery.data?.running_job ? POLL_MS : false),
});
@ -368,6 +328,9 @@ export function LldpLinksPage() {
const edgeItems = edgesQuery.data?.items ?? [];
const edgeTotal = Number(edgesQuery.data?.total || 0);
const edgePages = pageCount(edgeTotal, edgePageSize);
if (edgesQuery.data && edgePage > edgePages) setEdgePage(edgePages);
if (jobsQuery.data && jobPage > jobPages) setJobPage(jobPages);
if (jobDetailQuery.data && itemPage > itemPages) setItemPage(itemPages);
const exportEdgesCsv = async () => {
setExportingEdges(true);
@ -377,9 +340,7 @@ export function LldpLinksPage() {
maxRows: 2000,
fetchPage: (p, ps) =>
fetchFabricEdges({
status: edgeStatus === "all" ? "" : edgeStatus,
keyword: debouncedEdgeKeyword,
source: edgeSource || undefined,
columnFilters: edgeColumns.filters,
page: p,
pageSize: ps,
}),
@ -440,8 +401,7 @@ export function LldpLinksPage() {
fetchLldpCollectJobs({
page: p,
pageSize: ps,
status: jobStatus,
keyword: debouncedJobKeyword,
columnFilters: jobColumns.filters,
}),
});
downloadCsv(
@ -509,8 +469,7 @@ export function LldpLinksPage() {
const job = await fetchLldpCollectJob(expandedJobId, {
page: p,
pageSize: ps,
status: itemStatus,
keyword: debouncedItemKeyword,
columnFilters: itemColumns.filters,
});
return { total: job.items_total, items: (job.items || []) as TopologyDiscoverJobItem[] };
},
@ -749,24 +708,15 @@ export function LldpLinksPage() {
{scopeMode === "selected" ? (
<div style={{ marginTop: 12 }}>
<p className="muted">{t("lldpLinks.selectedCount", { count: String(selectedCount) })}</p>
<div className="filter-inline">
<input
value={targetKeyword}
placeholder={t("lldpLinks.targetKeywordPh")}
onChange={(e) => {
setTargetKeyword(e.target.value);
setTargetPage(1);
}}
/>
</div>
<table className="data-table">
<Button size="sm" variant="tertiary" isDisabled={!targetColumns.active} onPress={targetColumns.reset}>{t("common.clearFilters")}</Button>
<table className="data-table pt-list-table">
<thead>
<tr>
<th />
<th>{t("lldpLinks.col.source")}</th>
<th>{t("lldpLinks.col.name")}</th>
<th>IP</th>
<th>{t("lldpLinks.col.vendor")}</th>
<th>{targetColumns.column("source", t("lldpLinks.col.source"))}</th>
<th>{targetColumns.column("name", t("lldpLinks.col.name"))}</th>
<th>{targetColumns.column("ip", "IP")}</th>
<th>{targetColumns.column("vendor", t("lldpLinks.col.vendor"))}</th>
</tr>
</thead>
<tbody>
@ -823,42 +773,12 @@ export function LldpLinksPage() {
</div>
<h3>{t("lldpLinks.edgesTitle")}</h3>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<FieldSelect
value={edgeStatus}
onChange={(e) => {
const v = e.target.value;
setEdgeStatus(v === "active" || v === "missing" ? v : "all");
setEdgePage(1);
}}
>
<option value="all">{t("lldpLinks.edgeStatusAll")}</option>
<option value="active">{t("lldpLinks.edgeStatusActive")}</option>
<option value="missing">{t("lldpLinks.edgeStatusMissing")}</option>
</FieldSelect>
<FieldSelect
value={edgeSource}
onChange={(e) => {
setEdgeSource(e.target.value);
setEdgePage(1);
}}
>
<option value="">{t("lldpLinks.edgeSourceAll")}</option>
<option value="lldp">{t("lldpLinks.edgeSource.lldp")}</option>
<option value="manual">{t("lldpLinks.edgeSource.manual")}</option>
</FieldSelect>
<Input
value={edgeKeyword}
placeholder={t("lldpLinks.edgeKeywordPh")}
onChange={(e) => {
setEdgeKeyword(e.target.value);
setEdgePage(1);
}}
/>
<div className="btn-row" style={{ marginBottom: 8 }}>
<Button size="sm" variant="tertiary" isDisabled={!edgeColumns.active} onPress={edgeColumns.reset}>{t("common.clearFilters")}</Button>
<Button
size="sm"
variant="secondary"
isDisabled={exportingEdges || edgeTotal === 0}
isDisabled={exportingEdges || edgeTotal === 0 || edgesQuery.isFetching}
onPress={() => void exportEdgesCsv()}
>
{exportingEdges ? t("common.exporting") : t("common.exportCsv")}
@ -868,14 +788,14 @@ export function LldpLinksPage() {
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("lldpLinks.col.status")}</th>
<th>{t("lldpLinks.col.aSide")}</th>
<th>{t("lldpLinks.col.aPort")}</th>
<th>{t("lldpLinks.col.bSide")}</th>
<th>{t("lldpLinks.col.bPort")}</th>
<th>{t("lldpLinks.col.source")}</th>
<th>{t("lldpLinks.missCount")}</th>
<th>{t("lldpLinks.col.lastSeen")}</th>
<th>{edgeColumns.column("status", t("lldpLinks.col.status"), (value) => lldpEdgeStatusLabel(t, value))}</th>
<th>{edgeColumns.column("aSide", t("lldpLinks.col.aSide"))}</th>
<th>{edgeColumns.column("aPort", t("lldpLinks.col.aPort"))}</th>
<th>{edgeColumns.column("bSide", t("lldpLinks.col.bSide"))}</th>
<th>{edgeColumns.column("bPort", t("lldpLinks.col.bPort"))}</th>
<th>{edgeColumns.column("source", t("lldpLinks.col.source"), (value) => lldpEdgeSourceLabel(t, value))}</th>
<th>{edgeColumns.column("missCount", t("lldpLinks.missCount"))}</th>
<th>{edgeColumns.column("lastSeen", t("lldpLinks.col.lastSeen"), formatSystemTime)}</th>
</tr>
</thead>
<tbody>
@ -943,35 +863,12 @@ export function LldpLinksPage() {
/>
<h3 style={{ marginTop: 24 }}>{t("lldpLinks.jobsTitle")}</h3>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<FieldSelect
value={jobStatus}
onChange={(e) => {
setJobStatus(e.target.value);
setJobPage(1);
}}
>
<option value="">{t("lldpLinks.statusAll")}</option>
<option value="running">{t("lldpLinks.jobStatus.running")}</option>
<option value="pending">{t("lldpLinks.jobStatus.pending")}</option>
<option value="paused">{t("lldpLinks.jobStatus.paused")}</option>
<option value="done">{t("lldpLinks.jobStatus.done")}</option>
<option value="failed">{t("lldpLinks.jobStatus.failed")}</option>
<option value="cancelled">{t("lldpLinks.jobStatus.cancelled")}</option>
<option value="stopped">{t("lldpLinks.jobStatus.stopped")}</option>
</FieldSelect>
<Input
value={jobKeyword}
placeholder={t("lldpLinks.jobKeywordPh")}
onChange={(e) => {
setJobKeyword(e.target.value);
setJobPage(1);
}}
/>
<div className="btn-row" style={{ marginBottom: 8 }}>
<Button size="sm" variant="tertiary" isDisabled={!jobColumns.active} onPress={jobColumns.reset}>{t("common.clearFilters")}</Button>
<Button
size="sm"
variant="secondary"
isDisabled={exportingJobs || jobTotal === 0}
isDisabled={exportingJobs || jobTotal === 0 || jobsQuery.isFetching}
onPress={() => void exportJobsCsv()}
>
{exportingJobs ? t("common.exporting") : t("common.exportCsv")}
@ -982,15 +879,15 @@ export function LldpLinksPage() {
<thead>
<tr>
<th />
<th>ID</th>
<th>{t("lldpLinks.col.trigger")}</th>
<th>{t("lldpLinks.col.scope")}</th>
<th>{t("lldpLinks.col.status")}</th>
<th>{t("lldpLinks.col.progress")}</th>
<th>{t("lldpLinks.col.edges")}</th>
<th>{t("lldpLinks.col.missingDelta")}</th>
<th>{t("lldpLinks.col.started")}</th>
<th>{t("lldpLinks.col.ended")}</th>
<th>{jobColumns.column("id", "ID", (value) => value.slice(0, 8))}</th>
<th>{jobColumns.column("trigger", t("lldpLinks.col.trigger"), (value) => lldpTriggerLabel(t, value))}</th>
<th>{jobColumns.column("scope", t("lldpLinks.col.scope"), (value) => lldpScopeLabel(t, value))}</th>
<th>{jobColumns.column("status", t("lldpLinks.col.status"), (value) => lldpJobStatusLabel(t, value))}</th>
<th>{jobColumns.column("progress", t("lldpLinks.col.progress"))}</th>
<th>{jobColumns.column("edges", t("lldpLinks.col.edges"), (value) => { const [added, updated] = value.split("/"); return `+${added} / ~${updated}`; })}</th>
<th>{jobColumns.column("missingDelta", t("lldpLinks.col.missingDelta"))}</th>
<th>{jobColumns.column("started", t("lldpLinks.col.started"), formatSystemTime)}</th>
<th>{jobColumns.column("ended", t("lldpLinks.col.ended"), formatSystemTime)}</th>
<th />
</tr>
</thead>
@ -1004,8 +901,7 @@ export function LldpLinksPage() {
variant="ghost"
onPress={() => {
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
itemColumns.reset();
setExpandedJobId(job.id);
setItemPage(1);
}}
@ -1103,8 +999,7 @@ export function LldpLinksPage() {
onClose={() => {
setExpandedJobId("");
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
itemColumns.reset();
setItemPage(1);
}}
size="lg"
@ -1127,28 +1022,7 @@ export function LldpLinksPage() {
})()}
</p>
<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>
<Button size="sm" variant="tertiary" isDisabled={!itemColumns.active} onPress={itemColumns.reset}>{t("common.clearFilters")}</Button>
{jobDetailQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{jobDetailQuery.data?.error ? (
@ -1160,12 +1034,12 @@ export function LldpLinksPage() {
<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>{itemColumns.column("name", t("lldpLinks.col.name"))}</th>
<th>{itemColumns.column("ip", "IP")}</th>
<th>{itemColumns.column("status", t("lldpLinks.col.status"), (value) => t(`lldpLinks.itemResult.${value}`))}</th>
<th>{itemColumns.column("neighbors", t("lldpLinks.col.neighbors"))}</th>
<th>{itemColumns.column("edges", t("lldpLinks.col.edges"), (value) => { const [added, updated] = value.split("/"); return `+${added} / ~${updated}`; })}</th>
<th>{itemColumns.column("error", t("lldpLinks.col.error"))}</th>
<th />
</tr>
</thead>
@ -1202,7 +1076,7 @@ export function LldpLinksPage() {
</td>
<td className="muted">{it.error || "—"}</td>
<td>
<Button size="sm" variant="tertiary" onPress={() => setItemDetail(it)}>
<Button size="sm" variant="tertiary" onPress={() => { unmatchedColumns.reset(); setItemDetail(it); }}>
{t("topology.discoverViewDetail")}
</Button>
</td>
@ -1238,7 +1112,7 @@ export function LldpLinksPage() {
<Modal.Footer>
<Button
variant="secondary"
isDisabled={exportingItems || itemTotal === 0}
isDisabled={exportingItems || itemTotal === 0 || jobDetailQuery.isFetching}
onPress={() => void exportItemsCsv()}
>
{exportingItems ? t("common.exporting") : t("common.exportCsv")}
@ -1248,8 +1122,7 @@ export function LldpLinksPage() {
onPress={() => {
setExpandedJobId("");
setItemDetail(null);
setItemStatus("");
setItemKeyword("");
itemColumns.reset();
setItemPage(1);
}}
>
@ -1307,16 +1180,16 @@ export function LldpLinksPage() {
<p className="panel__hint">{t("topology.discoverUnmatchedEmpty")}</p>
) : (
<div className="topo-discover-modal__table-wrap">
<table className="topo-discover-modal__table">
<table className="data-table pt-list-table topo-discover-modal__table">
<thead>
<tr>
<th>{t("topology.discoverColRemote")}</th>
<th>{t("topology.discoverColLocalPort")}</th>
<th>{t("topology.discoverColRemotePort")}</th>
<th>{unmatchedColumns.column("remote", t("topology.discoverColRemote"))}</th>
<th>{unmatchedColumns.column("localPort", t("topology.discoverColLocalPort"))}</th>
<th>{unmatchedColumns.column("remotePort", t("topology.discoverColRemotePort"))}</th>
</tr>
</thead>
<tbody>
{(itemDetail.unmatched || []).map((u, idx) => (
{unmatchedColumns.items.map((u, idx) => (
<tr key={`${itemDetail.id}-u-${idx}`}>
<td>
{(u.remote_name || u.remote_ip || "?").trim()}
@ -1326,6 +1199,7 @@ export function LldpLinksPage() {
<td>{u.remote_port || "?"}</td>
</tr>
))}
{!unmatchedColumns.items.length ? <tr><td colSpan={3}>{t("common.empty")}</td></tr> : null}
</tbody>
</table>
</div>

View file

@ -1,11 +1,10 @@
import { Button, Input } from "@heroui/react";
import { useEffect, useState } from "react";
import { Button } from "@heroui/react";
import { 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 { useTableColumnFilters } from "../../hooks/useTableColumnFilters";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import { fetchUmeCurrentAlarms, formatErr } from "../../services/api";
@ -19,59 +18,34 @@ import { NmStatusChip, severityChipColor } from "../../components/NmChips";
export function NetworkAlarmsPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [searchParams] = useSearchParams();
const [curSeverity, setCurSeverity] = useState("");
const [curCleared, setCurCleared] = useState("");
const [curHostName, setCurHostName] = useState("");
const [curKeyword, setCurKeyword] = useState("");
const [searchParams, setSearchParams] = useSearchParams();
const curSeverity = searchParams.get("severity") || "";
const curCleared = searchParams.get("cleared") || "";
const curHostName = searchParams.get("host") || "";
const curKeyword = searchParams.get("keyword") || "";
const [curPage, setCurPage] = useState(1);
const [curPageSize, setCurPageSize] = useState(10);
const [exporting, setExporting] = useState(false);
// Deep-link from topology (and similar): ?host=&cleared=&severity=&keyword=
useEffect(() => {
const host = searchParams.get("host");
const cleared = searchParams.get("cleared");
const severity = searchParams.get("severity");
const keyword = searchParams.get("keyword");
let touched = false;
if (host != null) {
setCurHostName(host);
touched = true;
}
if (cleared != null) {
setCurCleared(cleared);
touched = true;
}
if (severity != null) {
setCurSeverity(severity);
touched = true;
}
if (keyword != null) {
setCurKeyword(keyword);
touched = true;
}
if (touched) setCurPage(1);
}, [searchParams]);
const debouncedKeyword = useDebouncedValue(curKeyword, 300);
const debouncedHost = useDebouncedValue(curHostName, 300);
const columns = useTableColumnFilters("/v1/ume/alarms", () => setCurPage(1), {
severity: curSeverity, is_cleared: curCleared, host_name: curHostName, keyword: curKeyword,
});
const currentQuery = useQuery({
queryKey: queryKeys.umeCurrentAlarms(
queryKey: [...queryKeys.umeCurrentAlarms(
curSeverity,
curCleared,
debouncedHost,
debouncedKeyword,
curHostName,
curKeyword,
curPage,
curPageSize,
),
), columns.key],
queryFn: () =>
fetchUmeCurrentAlarms({
columnFilters: columns.filters,
severity: curSeverity,
isCleared: curCleared,
hostName: debouncedHost,
keyword: debouncedKeyword,
hostName: curHostName,
keyword: curKeyword,
page: curPage,
pageSize: curPageSize,
}),
@ -81,7 +55,8 @@ export function NetworkAlarmsPage() {
const items = currentQuery.data?.items || [];
const curTotal = Number(currentQuery.data?.total || 0);
const curPages = pageCount(curTotal, curPageSize);
const hasFilters = Boolean(curKeyword.trim() || curHostName.trim() || curSeverity || curCleared);
const hasFilters = columns.active || Boolean(curKeyword.trim() || curHostName.trim() || curSeverity || curCleared);
if (currentQuery.data && curPage > curPages) setCurPage(curPages);
const exportCsv = async () => {
setExporting(true);
@ -91,10 +66,11 @@ export function NetworkAlarmsPage() {
maxRows: 2000,
fetchPage: (page, pageSize) =>
fetchUmeCurrentAlarms({
columnFilters: columns.filters,
severity: curSeverity,
isCleared: curCleared,
hostName: debouncedHost,
keyword: debouncedKeyword,
hostName: curHostName,
keyword: curKeyword,
page,
pageSize,
}),
@ -128,10 +104,14 @@ export function NetworkAlarmsPage() {
<div className="panel__toolbar">
<h2>{t("ume.alarms.title")}</h2>
<div className="btn-row">
{curHostName || curKeyword ? <span className="muted">{curHostName || curKeyword}</span> : null}
<Button size="sm" variant="tertiary" isDisabled={!hasFilters} onPress={() => { columns.reset(); setSearchParams({}); }}>
{t("common.clearFilters")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={exporting || curTotal === 0}
isDisabled={exporting || curTotal === 0 || currentQuery.isFetching}
onPress={() => void exportCsv()}
>
{exporting ? t("common.exporting") : t("common.exportCsv")}
@ -140,83 +120,20 @@ export function NetworkAlarmsPage() {
</div>
<div className="pt-list">
<div className="filter-inline">
<Input
value={curKeyword}
placeholder={t("ume.alarms.keywordPh")}
onChange={(e) => {
setCurKeyword(e.target.value);
setCurPage(1);
}}
/>
<Input
value={curHostName}
placeholder={t("ume.alarms.hostNamePh")}
onChange={(e) => {
setCurHostName(e.target.value);
setCurPage(1);
}}
/>
<FieldSelect
value={curSeverity}
onChange={(e) => {
setCurSeverity(e.target.value);
setCurPage(1);
}}
>
<option value="">{t("ume.alarms.allSeverity")}</option>
<option value="critical">critical</option>
<option value="major">major</option>
<option value="minor">minor</option>
<option value="warning">warning</option>
<option value="info">info</option>
</FieldSelect>
<FieldSelect
value={curCleared}
onChange={(e) => {
setCurCleared(e.target.value);
setCurPage(1);
}}
>
<option value="">{t("ume.alarms.clearedAll")}</option>
<option value="false">{t("ume.alarms.clearedNo")}</option>
<option value="true">{t("ume.alarms.clearedYes")}</option>
</FieldSelect>
<Button
size="sm"
variant="tertiary"
aria-label={t("ume.alarms.clearTitle")}
onPress={() => {
setCurKeyword("");
setCurHostName("");
setCurSeverity("");
setCurCleared("");
setCurPage(1);
}}
isDisabled={!hasFilters}
>
{t("common.clearFilters")}
</Button>
</div>
{currentQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{currentQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!items.length && !currentQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("common.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("ume.alarms.col.time")}</th>
<th>{t("ume.alarms.col.severity")}</th>
<th>{t("ume.alarms.col.neId")}</th>
<th>{t("ume.alarms.col.hostName")}</th>
<th>{t("ume.alarms.col.neType")}</th>
<th>{t("ume.alarms.col.cause")}</th>
<th>{columns.column("time", t("ume.alarms.col.time"), formatSystemTime)}</th>
<th>{columns.column("severity", t("ume.alarms.col.severity"))}</th>
<th>{columns.column("neId", t("ume.alarms.col.neId"))}</th>
<th>{columns.column("hostName", t("ume.alarms.col.hostName"))}</th>
<th>{columns.column("neType", t("ume.alarms.col.neType"))}</th>
<th>{columns.column("cause", t("ume.alarms.col.cause"))}</th>
<th>{columns.column("cleared", t("ume.alarms.col.cleared"), (value) => t(value === "true" ? "ume.alarms.clearedYes" : "ume.alarms.clearedNo"))}</th>
</tr>
</thead>
<tbody>
@ -236,9 +153,7 @@ export function NetworkAlarmsPage() {
variant="ghost"
className="link-btn pt-list-task-name"
onPress={() => {
setCurHostName(x.host_name || "");
setCurKeyword("");
setCurPage(1);
columns.setFilter("hostName", { mode: "include", values: [x.host_name || ""] });
}}
aria-label={t("ume.alarms.filterByHost")}
>
@ -252,12 +167,15 @@ export function NetworkAlarmsPage() {
</td>
<td>{x.ne_type ?? ""}</td>
<td>{x.native_probable_cause}</td>
<td>{t(x.is_cleared === "true" ? "ume.alarms.clearedYes" : "ume.alarms.clearedNo")}</td>
</tr>
))}
{!items.length && !currentQuery.isFetching && !currentQuery.isError ? (
<tr><td colSpan={7} className="pt-list-empty">{t("common.empty")}</td></tr>
) : null}
</tbody>
</table>
</div>
)}
<ListPager
page={curPage}

View file

@ -1,12 +1,11 @@
import { Button, Input, Modal } from "@heroui/react";
import { useEffect, useState } from "react";
import { Button, Modal } from "@heroui/react";
import { 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 { useTableColumnFilters } from "../../hooks/useTableColumnFilters";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import {
@ -35,41 +34,22 @@ export function NetworkConfigsPage() {
const [searchParams, setSearchParams] = useSearchParams();
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [keyword, setKeyword] = useState(() => String(searchParams.get("q") || ""));
const [source, setSource] = useState("");
const [vendor, setVendor] = useState("");
const keyword = searchParams.get("q") || "";
const [selected, setSelected] = useState<{ source: string; id: string } | null>(null);
const [tab, setTab] = useState<"primary" | "alt">("primary");
const [exporting, setExporting] = useState("");
const [exportingList, setExportingList] = useState(false);
const debouncedKeyword = useDebouncedValue(keyword, 300);
const debouncedVendor = useDebouncedValue(vendor, 300);
useEffect(() => {
const q = String(searchParams.get("q") || "").trim();
if (!q) return;
setKeyword(q);
setPage(1);
setSearchParams(
(prev) => {
const next = new URLSearchParams(prev);
if (next.get("q") === q) next.delete("q");
return next;
},
{ replace: true },
);
}, [searchParams, setSearchParams]);
const columns = useTableColumnFilters("/v1/config-sync/snapshots", () => setPage(1), { keyword });
const listQuery = useQuery({
queryKey: queryKeys.networkConfigs(page, debouncedKeyword, source, debouncedVendor, pageSize),
queryKey: [...queryKeys.networkConfigs(page, keyword, "", "", pageSize), columns.key],
queryFn: () =>
fetchNeConfigSnapshots({
page,
pageSize,
keyword: debouncedKeyword,
source,
vendor: debouncedVendor,
keyword,
columnFilters: columns.filters,
}),
staleTime: 5000,
});
@ -86,7 +66,8 @@ export function NetworkConfigsPage() {
const pages = pageCount(total, pageSize);
const detail = detailQuery.data;
const showAlt = Boolean(detail?.has_alt);
const hasFilters = Boolean(keyword || source || vendor);
const hasFilters = Boolean(keyword) || columns.active;
if (listQuery.data && page > pages) setPage(pages);
const exportConfig = async (
src: string,
@ -115,9 +96,8 @@ export function NetworkConfigsPage() {
fetchNeConfigSnapshots({
page: p,
pageSize: ps,
keyword: debouncedKeyword,
source,
vendor: debouncedVendor,
keyword,
columnFilters: columns.filters,
}),
});
downloadCsv(
@ -154,10 +134,14 @@ export function NetworkConfigsPage() {
<div className="panel__toolbar">
<h2>{t("networkConfigs.title")}</h2>
<div className="btn-row">
{keyword ? <span className="muted">{keyword}</span> : null}
<Button size="sm" variant="tertiary" isDisabled={!hasFilters} onPress={() => { columns.reset(); setSearchParams({}); }}>
{t("common.clearFilters")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={exportingList || total === 0}
isDisabled={exportingList || total === 0 || listQuery.isFetching}
onPress={() => void exportListCsv()}
>
{exportingList ? t("common.exporting") : t("common.exportCsv")}
@ -166,67 +150,19 @@ export function NetworkConfigsPage() {
</div>
<div className="pt-list">
<div className="filter-inline">
<Input
value={keyword}
placeholder={t("networkConfigs.keywordPh")}
onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
}}
/>
<FieldSelect
value={source}
onChange={(e) => {
setSource(e.target.value);
setPage(1);
}}
>
<option value="">{t("networkConfigs.allSource")}</option>
<option value="managed">managed</option>
<option value="ume">ume</option>
</FieldSelect>
<Input
value={vendor}
placeholder={t("networkConfigs.vendorPh")}
onChange={(e) => {
setVendor(e.target.value);
setPage(1);
}}
/>
<Button
size="sm"
variant="tertiary"
isDisabled={!hasFilters}
onPress={() => {
setKeyword("");
setSource("");
setVendor("");
setPage(1);
}}
>
{t("common.clearFilters")}
</Button>
</div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{listQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!items.length && !listQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("networkConfigs.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("networkConfigs.col.name")}</th>
<th>IP</th>
<th>{t("networkConfigs.col.vendor")}</th>
<th>{t("networkConfigs.col.source")}</th>
<th>{t("networkConfigs.col.size")}</th>
<th>{t("networkConfigs.col.collected")}</th>
<th>{columns.column("name", t("networkConfigs.col.name"))}</th>
<th>{columns.column("ip", "IP")}</th>
<th>{columns.column("vendor", t("networkConfigs.col.vendor"))}</th>
<th>{columns.column("source", t("networkConfigs.col.source"))}</th>
<th>{columns.column("size", t("networkConfigs.col.size"), (value) => fmtBytes(Number(value)))}</th>
<th>{columns.column("collected", t("networkConfigs.col.collected"), formatSystemTime)}</th>
<th>{t("portTraffic.col.actions")}</th>
</tr>
</thead>
@ -292,10 +228,12 @@ export function NetworkConfigsPage() {
</tr>
);
})}
{!items.length && !listQuery.isFetching && !listQuery.isError ? (
<tr><td colSpan={7} className="pt-list-empty">{t("networkConfigs.empty")}</td></tr>
) : null}
</tbody>
</table>
</div>
)}
<ListPager
page={page}

View file

@ -1,14 +1,13 @@
import { Button, Input } from "@heroui/react";
import { Button } 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 { ColumnFilterMenu } from "../../components/ui/ColumnFilterMenu";
import { queryKeys } from "../../constants/queryKeys";
import { useDebouncedValue } from "../../hooks/useDebouncedValue";
import { useToast } from "../../hooks/useToast";
import { useI18n } from "../../i18n";
import { fetchCliTargets, formatErr } from "../../services/api";
import type { CliTargetItem } from "../../types";
import { fetchCliTargetFilterOptions, fetchCliTargets, formatErr } from "../../services/api";
import type { CliTargetColumnFilter, CliTargetFilterField, CliTargetItem } from "../../types";
import { downloadCsv, fetchAllPages } from "../../utils/csvExport";
import { pageCount } from "../../utils/display";
import { openNewModuleWindow } from "../../utils/moduleWindows";
@ -22,28 +21,50 @@ function openWebcrt(row: CliTargetItem) {
openNewModuleWindow({ moduleId: "webcrt", path });
}
type DeviceColumnFilters = Record<CliTargetFilterField, CliTargetColumnFilter>;
const EMPTY_COLUMN_FILTERS: DeviceColumnFilters = {
source: null,
name: null,
ip: null,
vendor: null,
deviceType: null,
connectStatus: null,
};
/** Inventory of managed + UME NEs for Network Management. */
export function NetworkDevicesPage() {
const { t } = useI18n();
const { showOk, showError } = useToast();
const [keyword, setKeyword] = useState("");
const [source, setSource] = useState<"all" | "managed" | "ume">("all");
const [columnFilters, setColumnFilters] = useState<DeviceColumnFilters>(EMPTY_COLUMN_FILTERS);
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [exporting, setExporting] = useState(false);
const debouncedKeyword = useDebouncedValue(keyword, 300);
const listQuery = useQuery({
queryKey: queryKeys.cliTargets(`${source}:${debouncedKeyword}`, page, pageSize),
queryFn: () => fetchCliTargets({ source, keyword: debouncedKeyword, page, pageSize }),
queryKey: queryKeys.cliTargets(
JSON.stringify(columnFilters),
page,
pageSize,
),
queryFn: () =>
fetchCliTargets({
source: "inventory",
columnFilters,
page,
pageSize,
}),
staleTime: 5000,
});
const items = listQuery.data?.items ?? [];
const total = Number(listQuery.data?.total || 0);
const pages = pageCount(total, pageSize);
const hasFilters = Boolean(keyword.trim() || source !== "all");
const hasFilters =
Object.values(columnFilters).some(Boolean);
if (listQuery.data && page > pages) setPage(pages);
const exportCsv = async () => {
setExporting(true);
@ -52,7 +73,12 @@ export function NetworkDevicesPage() {
pageSize: 200,
maxRows: 2000,
fetchPage: (p, ps) =>
fetchCliTargets({ source, keyword: debouncedKeyword, page: p, pageSize: ps }),
fetchCliTargets({
source: "inventory",
columnFilters,
page: p,
pageSize: ps,
}),
});
downloadCsv(`${t("networkDevices.exportName")}-${new Date().toISOString().slice(0, 10)}.csv`, rows, [
{ key: "source", header: t("networkDevices.col.source") },
@ -84,10 +110,13 @@ export function NetworkDevicesPage() {
<div className="panel__toolbar">
<h2>{t("networkDevices.title")}</h2>
<div className="btn-row">
<Button size="sm" variant="tertiary" isDisabled={!hasFilters} onPress={() => { setColumnFilters(EMPTY_COLUMN_FILTERS); setPage(1); }}>
{t("common.clearFilters")}
</Button>
<Button
size="sm"
variant="secondary"
isDisabled={exporting || total === 0}
isDisabled={exporting || total === 0 || listQuery.isFetching}
onPress={() => void exportCsv()}
>
{exporting ? t("common.exporting") : t("common.exportCsv")}
@ -96,101 +125,88 @@ export function NetworkDevicesPage() {
</div>
<div className="pt-list">
<div className="filter-inline">
<Input
value={keyword}
placeholder={t("networkDevices.keywordPh")}
onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
}}
/>
<FieldSelect
value={source}
onChange={(e) => {
setSource(e.target.value as "all" | "managed" | "ume");
setPage(1);
}}
>
<option value="all">{t("networkDevices.allSource")}</option>
<option value="managed">managed</option>
<option value="ume">ume</option>
</FieldSelect>
<Button
size="sm"
variant="tertiary"
isDisabled={!hasFilters}
onPress={() => {
setKeyword("");
setSource("all");
setPage(1);
}}
>
{t("common.clearFilters")}
</Button>
</div>
{listQuery.isLoading ? <p className="muted">{t("common.refreshing")}</p> : null}
{listQuery.isFetching ? <p className="muted">{t("common.refreshing")}</p> : null}
{listQuery.isError ? <p className="error-text">{t("common.opFailed")}</p> : null}
{!items.length && !listQuery.isLoading ? (
<div className="pt-list-empty">
<p>{t("networkDevices.empty")}</p>
</div>
) : (
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
<th>{t("networkDevices.col.source")}</th>
<th>{t("networkDevices.col.name")}</th>
<th>IP</th>
<th>{t("networkDevices.col.vendor")}</th>
<th>{t("networkDevices.col.deviceType")}</th>
<th>{t("networkDevices.col.connect")}</th>
<th>{t("networkDevices.col.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={`${row.source}:${row.id}`}>
<td>
<NmStatusChip color={sourceChipColor(row.source)}>{row.source}</NmStatusChip>
</td>
<td className="pt-list-task-name">{row.name || row.id}</td>
<td className="pt-list-num">{row.ip_address || "—"}</td>
<td>{row.vendor || "—"}</td>
<td>{row.device_type || row.ne_type || "—"}</td>
<td>
<NmStatusChip color={connectChipColor(row.connect_status)}>
{row.connect_status || "—"}
</NmStatusChip>
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<Button size="sm" variant="ghost" onPress={() => openWebcrt(row)}>
WebCRT
</Button>
<Button
size="sm"
variant="ghost"
onPress={() =>
openNewModuleWindow({
moduleId: "network",
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
})
}
>
{t("network.nav.configs")}
</Button>
</div>
</td>
</tr>
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table">
<thead>
<tr>
{([
["source", t("networkDevices.col.source")],
["name", t("networkDevices.col.name")],
["ip", "IP"],
["vendor", t("networkDevices.col.vendor")],
["deviceType", t("networkDevices.col.deviceType")],
["connectStatus", t("networkDevices.col.connect")],
] as const).map(([key, label]) => (
<th key={key}>
<span className="ne-column-heading">
{label}
<ColumnFilterMenu
label={label}
value={columnFilters[key]}
queryKey={["cliTargetFilterOptions", key, "inventory", ""]}
loadOptions={() => fetchCliTargetFilterOptions({ field: key, source: "inventory" }).then((result) => result.options)}
onApply={(value) => {
setColumnFilters((current) => ({ ...current, [key]: value }));
setPage(1);
}}
/>
</span>
</th>
))}
</tbody>
</table>
<th>{t("networkDevices.col.actions")}</th>
</tr>
</thead>
<tbody>
{items.map((row) => (
<tr key={`${row.source}:${row.id}`}>
<td>
<NmStatusChip color={sourceChipColor(row.source)}>{row.source}</NmStatusChip>
</td>
<td className="pt-list-task-name">{row.name || row.id}</td>
<td className="pt-list-num">{row.ip_address || "—"}</td>
<td>{row.vendor || "—"}</td>
<td>{row.device_type || row.ne_type || "—"}</td>
<td>
<NmStatusChip color={connectChipColor(row.connect_status)}>
{row.connect_status || "—"}
</NmStatusChip>
</td>
<td>
<div className="btn-row pt-list-actions table-actions">
<Button size="sm" variant="ghost" onPress={() => openWebcrt(row)}>
WebCRT
</Button>
<Button
size="sm"
variant="ghost"
onPress={() =>
openNewModuleWindow({
moduleId: "network",
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
})
}
>
{t("network.nav.configs")}
</Button>
</div>
</td>
</tr>
))}
{!items.length && !listQuery.isFetching && !listQuery.isError ? (
<tr>
<td colSpan={7}>
<div className="pt-list-empty">
<p>{t(hasFilters ? "networkDevices.emptyFiltered" : "networkDevices.empty")}</p>
</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
)}
<ListPager
page={page}

View file

@ -10,6 +10,7 @@ import {
matchFabricNodes,
patchFabricNodeTags,
} from "../../services/api";
import { useTableColumnFilters } from "../../hooks/useTableColumnFilters";
import { ListPager } from "../../components/ListPager";
import { queryKeys } from "../../constants/queryKeys";
import { useI18n } from "../../i18n";
@ -49,14 +50,6 @@ const LEVEL_PRESETS = [
{ value: "3.1", labelKey: "topoClassify.levelAccessSub" },
] as const;
const FILTER_MAJOR = [
{ value: "", labelKey: "topoClassify.filterLevelAll" },
{ value: "0", labelKey: "topoClassify.levelExternal" },
{ value: "1", labelKey: "topoClassify.levelCore" },
{ value: "2", labelKey: "topoClassify.levelAgg" },
{ value: "3", labelKey: "topoClassify.levelAccess" },
] as const;
function formatLevel(level: number | null | undefined): string {
if (level === null || level === undefined || Number.isNaN(Number(level))) return "—";
const n = Number(level);
@ -92,11 +85,6 @@ export function TopologyClassifyPage() {
const { showOk, showError } = useToast();
const queryClient = useQueryClient();
const [keyword, setKeyword] = useState("");
const [filterMajor, setFilterMajor] = useState("");
const [filterRegion, setFilterRegion] = useState("");
const [unmatched, setUnmatched] = useState("");
const [linkStatus, setLinkStatus] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
@ -139,27 +127,12 @@ export function TopologyClassifyPage() {
return m;
}, [regions]);
const columns = useTableColumnFilters("/v1/topology/fabric/nodes", () => {
setPage(1); setSelected({}); setEditingId("");
});
const listQuery = useQuery({
queryKey: queryKeys.fabricNodeInventory(
keyword,
"",
filterRegion,
unmatched,
linkStatus,
page,
filterMajor,
pageSize,
),
queryFn: () =>
fetchFabricNodes({
keyword,
levelMajor: filterMajor || undefined,
regionFolderId: filterRegion,
unmatched,
linkStatus,
page,
pageSize,
}),
queryKey: ["fabricNodeInventory", columns.key, page, pageSize],
queryFn: () => fetchFabricNodes({ columnFilters: columns.filters, page, pageSize }),
});
const invalidateList = async () => {
@ -252,6 +225,7 @@ export function TopologyClassifyPage() {
.map((n) => n.id);
const total = listQuery.data?.total || 0;
const pages = pageCount(total, pageSize);
if (listQuery.data && page > pages) setPage(pages);
const selectedCount = Object.values(selected).filter(Boolean).length;
const highlight = useMemo(() => new Set(matchedIds), [matchedIds]);
@ -281,7 +255,8 @@ export function TopologyClassifyPage() {
<Button
variant="primary"
onPress={() => {
const regionId = filterRegion || assignRegion || "";
const regionFilter = columns.filters.region;
const regionId = (regionFilter?.mode === "include" && regionFilter.values.length === 1 ? regionFilter.values[0] : "") || assignRegion || "";
const folder = regions.find((r) => r.id === regionId);
const views = folder?.views || [];
const primary =
@ -298,68 +273,10 @@ export function TopologyClassifyPage() {
</div>
<section className="topo-classify__section topo-classify__section--first">
<h3>{t("topoClassify.inventory")}</h3>
<div className="topo-classify__draft">
<Input
placeholder={t("topoClassify.keywordPh")}
value={keyword}
onChange={(e) => {
setKeyword(e.target.value);
setPage(1);
}}
/>
<select className="input"
value={filterMajor}
onChange={(e) => {
setFilterMajor(e.target.value);
setPage(1);
}}
>
{FILTER_MAJOR.map((r) => (
<option key={r.value || "all"} value={r.value}>
{t(r.labelKey)}
</option>
))}
</select>
<select className="input"
value={filterRegion}
onChange={(e) => {
setFilterRegion(e.target.value);
setPage(1);
}}
>
<option value="">{t("topoClassify.filterRegionAll")}</option>
{regions.map((r) => (
<option key={r.id} value={r.id}>
{r.name}
</option>
))}
</select>
<select className="input"
value={unmatched}
onChange={(e) => {
setUnmatched(e.target.value);
setPage(1);
}}
>
<option value="">{t("topoClassify.filterUnmatchedOff")}</option>
<option value="any">{t("topoClassify.kindAny")}</option>
<option value="level">{t("topoClassify.kindLevel")}</option>
<option value="region">{t("topoClassify.kindRegion")}</option>
</select>
<select className="input"
value={linkStatus}
onChange={(e) => {
setLinkStatus(e.target.value);
setPage(1);
}}
>
<option value="">{t("topoClassify.filterLinkAll")}</option>
<option value="linked">{t("topoClassify.filterLinkLinked")}</option>
<option value="orphaned">{t("topoClassify.filterLinkOrphaned")}</option>
</select>
<div className="panel__toolbar">
<h3>{t("topoClassify.inventory")}</h3>
<Button size="sm" variant="tertiary" isDisabled={!columns.active} onPress={columns.reset}>{t("common.clearFilters")}</Button>
</div>
<div className="topo-classify__regex-bar">
<strong>{t("topoClassify.regexTitle")}</strong>
<Input
@ -456,7 +373,8 @@ export function TopologyClassifyPage() {
</span>
</div>
<table className="data-table topo-classify__table">
<div className="pt-list-table-wrap">
<table className="data-table pt-list-table topo-classify__table">
<thead>
<tr>
<th>
@ -467,11 +385,11 @@ export function TopologyClassifyPage() {
aria-label={t("topoClassify.selectPage")}
/>
</th>
<th>{t("topoClassify.colNe")}</th>
<th>IP</th>
<th>{t("topoClassify.colLink")}</th>
<th>{t("topoClassify.colLevel")}</th>
<th>{t("topoClassify.colRegion")}</th>
<th>{columns.column("name", t("topoClassify.colNe"))}</th>
<th>{columns.column("ip", "IP")}</th>
<th>{columns.column("link", t("topoClassify.colLink"), linkLabel)}</th>
<th>{columns.column("level", t("topoClassify.colLevel"), (value) => formatLevel(Number(value)))}</th>
<th>{columns.column("region", t("topoClassify.colRegion"), (value) => regionName[value] || value)}</th>
<th>{t("topoClassify.colActions")}</th>
</tr>
</thead>
@ -573,6 +491,7 @@ export function TopologyClassifyPage() {
) : null}
</tbody>
</table>
</div>
<ListPager
page={page}
pages={pages}

View file

@ -0,0 +1,25 @@
import { useState } from "react";
import { ColumnFilterMenu } from "../components/ui/ColumnFilterMenu";
import type { TableColumnFilters } from "../types/tableFilters";
/** For detail tables whose complete displayed dataset is already in memory. */
export function useLocalTableColumnFilters<T>(scope: string, rows: T[], values: (row: T) => Record<string, string>) {
const [filters, setFilters] = useState<TableColumnFilters>({});
return {
items: rows.filter((row) => Object.entries(filters).every(([field, filter]) => {
if (!filter) return true;
const included = filter.values.includes(values(row)[field] ?? "");
return filter.mode === "include" ? included : !included;
})),
reset: () => setFilters({}),
column: (field: string, label: string) => (
<span className="ne-column-heading">
{label}
<ColumnFilterMenu label={label} value={filters[field] ?? null}
queryKey={["localTableFilterOptions", scope, field, JSON.stringify(rows.map((row) => values(row)[field] ?? ""))]}
loadOptions={() => Promise.resolve([...new Set(rows.map((row) => values(row)[field] ?? ""))].sort())}
onApply={(value) => setFilters((current) => ({ ...current, [field]: value }))} />
</span>
),
};
}

View file

@ -0,0 +1,34 @@
import { useState } from "react";
import { ColumnFilterMenu } from "../components/ui/ColumnFilterMenu";
import { fetchTableFilterOptions } from "../services/api/tableFilters";
import type { ColumnValueFilter, TableColumnFilters } from "../types/tableFilters";
export function useTableColumnFilters(endpoint: string, onChange: () => void, scope: Record<string, string> = {}) {
const [filters, setFilters] = useState<TableColumnFilters>({});
const [currentEndpoint, setCurrentEndpoint] = useState(endpoint);
if (currentEndpoint !== endpoint) {
setCurrentEndpoint(endpoint);
setFilters({});
}
const scopeKey = JSON.stringify(scope);
const setFilter = (field: string, value: ColumnValueFilter) => {
setFilters((current) => ({ ...current, [field]: value }));
onChange();
};
return {
filters,
key: JSON.stringify(filters),
active: Object.values(filters).some(Boolean),
setFilter,
reset: () => { setFilters({}); onChange(); },
column: (field: string, label: string, formatValue?: (value: string) => string) => (
<span className="ne-column-heading">
{label}
<ColumnFilterMenu label={label} value={filters[field] ?? null}
queryKey={["tableFilterOptions", endpoint, scopeKey, field]}
loadOptions={() => fetchTableFilterOptions(endpoint, field, scope)}
formatValue={formatValue} onApply={(value) => setFilter(field, value)} />
</span>
),
};
}

View file

@ -3,6 +3,18 @@ const networkDevices = {
keywordPh: "Name / IP / ID",
allSource: "All sources",
empty: "No network elements.",
emptyFiltered: "No network elements match these filters. Adjust or clear the filters.",
columnFilterPh: "Search values",
selectAllColumnValues: "Select all",
applyColumnFilter: "Apply",
clearColumnFilter: "Clear filter",
blankFilterValue: "(Blanks)",
loadingFilterOptions: "Loading values…",
noFilterOptions: "No values available",
cancelColumnFilter: "Cancel",
filterOptionsError: "Failed to load values",
retryFilterOptions: "Retry",
moreFilterOptions: "Show more",
exportName: "network-devices",
col: {
source: "Source",

View file

@ -271,6 +271,7 @@ const ume = {
hostName: "Host",
neType: "NE type",
cause: "Cause",
cleared: "Cleared status",
},
},
};

View file

@ -3,6 +3,18 @@ const networkDevices = {
keywordPh: "名称 / IP / ID",
allSource: "全部来源",
empty: "暂无网元。",
emptyFiltered: "没有符合当前筛选条件的网元,请调整或清除筛选条件。",
columnFilterPh: "搜索值",
selectAllColumnValues: "全选",
applyColumnFilter: "应用",
clearColumnFilter: "清除筛选",
blankFilterValue: "(空白)",
loadingFilterOptions: "正在加载选项…",
noFilterOptions: "没有可选值",
cancelColumnFilter: "取消",
filterOptionsError: "选项加载失败",
retryFilterOptions: "重试",
moreFilterOptions: "显示更多",
exportName: "network-devices",
col: {
source: "来源",

View file

@ -271,6 +271,7 @@ const ume = {
hostName: "主机名",
neType: "网元类型",
cause: "原因",
cleared: "清除状态",
},
},
};

View file

@ -1,6 +1,11 @@
import type { TableColumnFilters } from "../../types/tableFilters";
import { appendColumnFilters } from "./tableFilters";
import type {
CliConnectProfileItem,
CliMeta,
CliTargetColumnFilter,
CliTargetFilterField,
CliTargetFilterOptions,
CliTargetListResponse,
UmeAlarmItem,
UmeCliOverrideItem,
@ -12,9 +17,20 @@ export const fetchCliMeta = () => apiGet<CliMeta>("/v1/cli/meta");
export const fetchCliProfiles = () =>
apiGet<{ items: CliConnectProfileItem[] }>("/v1/cli/profiles");
export const fetchCliTargets = (params: {
source?: "managed" | "ume" | "webcrt" | "all";
export const fetchCliTargetFilterOptions = (params: {
field: CliTargetFilterField;
source?: "managed" | "ume" | "webcrt" | "inventory" | "all";
keyword?: string;
}) => {
const p = new URLSearchParams({ field: params.field, source: params.source || "inventory" });
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
return apiGet<CliTargetFilterOptions>(`/v1/cli/targets/filter-options?${p.toString()}`);
};
export const fetchCliTargets = (params: {
source?: "managed" | "ume" | "webcrt" | "inventory" | "all";
keyword?: string;
columnFilters?: TableColumnFilters;
page?: number;
pageSize?: number;
}) => {
@ -23,6 +39,20 @@ export const fetchCliTargets = (params: {
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());
p.set("page", String(Math.max(1, Number(params.page || 1))));
p.set("page_size", String(Math.max(1, Math.min(500, Number(params.pageSize || 50)))));
const filters = params.columnFilters;
const filterParams: Record<CliTargetFilterField, string> = {
source: "source_filter",
name: "name_filter",
ip: "ip_filter",
vendor: "vendor_filter",
deviceType: "device_type_filter",
connectStatus: "connect_status_filter",
};
for (const [field, filter] of Object.entries(filters || {}) as [CliTargetFilterField, CliTargetColumnFilter][]) {
if (!filter) continue;
p.set(`${filterParams[field]}_mode`, filter.mode);
for (const value of filter.values) p.append(filterParams[field], value);
}
return apiGet<CliTargetListResponse>(`/v1/cli/targets?${p.toString()}`);
};
@ -33,6 +63,7 @@ export const fetchUmeCliOverride = (umeNeId: string) =>
apiGet<UmeCliOverrideItem | null>(`/v1/cli/ume-overrides/${encodeURIComponent(umeNeId)}`);
export const fetchUmeCurrentAlarms = (params: {
columnFilters?: TableColumnFilters;
severity: string;
isCleared: string;
hostName: string;
@ -41,6 +72,7 @@ export const fetchUmeCurrentAlarms = (params: {
pageSize: number;
}) => {
const p = new URLSearchParams();
appendColumnFilters(p, params?.columnFilters);
if (params.severity) p.set("severity", params.severity);
if (params.isCleared) p.set("is_cleared", params.isCleared);
if (params.hostName) p.set("host_name", params.hostName);

View file

@ -1,3 +1,5 @@
import type { TableColumnFilters } from "../../types/tableFilters";
import { appendColumnFilters } from "./tableFilters";
import type {
ConfigSyncCycle,
ConfigSyncDashboard,
@ -62,6 +64,7 @@ export const fetchConfigSyncCycleTasks = (params: {
};
export const fetchNeConfigSnapshots = (params: {
columnFilters?: TableColumnFilters;
page?: number;
pageSize?: number;
keyword?: string;
@ -69,6 +72,7 @@ export const fetchNeConfigSnapshots = (params: {
vendor?: string;
}) => {
const p = new URLSearchParams();
appendColumnFilters(p, params?.columnFilters);
p.set("page", String(Math.max(1, Number(params.page || 1))));
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
if (params.keyword?.trim()) p.set("keyword", params.keyword.trim());

View file

@ -0,0 +1,13 @@
import type { TableColumnFilters } from "../../types/tableFilters";
import { apiGet } from "./http";
export function appendColumnFilters(params: URLSearchParams, filters?: TableColumnFilters) {
const active = Object.fromEntries(Object.entries(filters ?? {}).filter(([, filter]) => filter !== null));
if (Object.keys(active).length) params.set("column_filters", JSON.stringify(active));
}
export function fetchTableFilterOptions(endpoint: string, field: string, scope: Record<string, string> = {}) {
const params = new URLSearchParams(scope);
params.set("filter_field", field);
return apiGet<{ field: string; options: string[] }>(`${endpoint}?${params}`).then((data) => data.options);
}

View file

@ -1,3 +1,5 @@
import type { TableColumnFilters } from "../../types/tableFilters";
import { appendColumnFilters } from "./tableFilters";
import type {
FabricEdge,
FabricSummary,
@ -203,6 +205,7 @@ export const patchTopologyEdgeStyle = (
export const fetchFabricSummary = () => apiGet<FabricSummary>("/v1/topology/fabric/summary");
export const fetchFabricNodes = (params?: {
columnFilters?: TableColumnFilters;
keyword?: string;
role?: string;
level?: string;
@ -214,6 +217,7 @@ export const fetchFabricNodes = (params?: {
pageSize?: number;
}) => {
const p = new URLSearchParams();
appendColumnFilters(p, params?.columnFilters);
if (params?.keyword) p.set("keyword", params.keyword);
if (params?.role) p.set("role", params.role);
if (params?.level) p.set("level", params.level);
@ -309,6 +313,7 @@ export const searchFabricNodes = (params?: { q?: string; page?: number; pageSize
};
export const fetchFabricEdges = (params?: {
columnFilters?: TableColumnFilters;
keyword?: string;
status?: string;
source?: string;
@ -317,6 +322,7 @@ export const fetchFabricEdges = (params?: {
pageSize?: number;
}) => {
const p = new URLSearchParams();
appendColumnFilters(p, params?.columnFilters);
if (params?.keyword) p.set("keyword", params.keyword);
if (params?.status) p.set("status", params.status);
if (params?.source) p.set("source", params.source);
@ -370,12 +376,14 @@ export const stopLldpCollectJob = (jobId: string) =>
apiPost<TopologyDiscoverJob>(`/v1/topology/lldp-collect/jobs/${encodeURIComponent(jobId)}/stop`, {});
export const fetchLldpCollectJobs = (params: {
columnFilters?: TableColumnFilters;
page?: number;
pageSize?: number;
status?: string;
keyword?: string;
}) => {
const p = new URLSearchParams();
appendColumnFilters(p, params?.columnFilters);
p.set("page", String(Math.max(1, Number(params.page || 1))));
p.set("page_size", String(Math.max(1, Math.min(100, Number(params.pageSize || 20)))));
if (params.status?.trim()) p.set("status", params.status.trim());
@ -387,9 +395,10 @@ export const fetchLldpCollectJobs = (params: {
export const fetchLldpCollectJob = (
jobId: string,
params?: { page?: number; pageSize?: number; status?: string; keyword?: string },
params?: { page?: number; pageSize?: number; status?: string; keyword?: string; columnFilters?: TableColumnFilters },
) => {
const p = new URLSearchParams();
appendColumnFilters(p, params?.columnFilters);
p.set("page", String(Math.max(1, Number(params?.page || 1))));
p.set("page_size", String(Math.max(1, Math.min(100, Number(params?.pageSize || 20)))));
if (params?.status?.trim()) p.set("status", params.status.trim());

View file

@ -341,6 +341,126 @@
margin: 0;
}
.ne-column-heading {
display: inline-flex;
align-items: center;
gap: 4px;
}
.ne-column-filter {
position: relative;
display: inline-flex;
}
.ne-column-filter > button.ne-column-filter__trigger {
display: inline-grid;
width: 22px;
height: 22px;
padding: 0;
place-items: center;
border: 0;
border-radius: 0;
background: transparent;
box-shadow: none;
color: var(--nx-text-muted);
cursor: pointer;
font-size: 12px;
line-height: 1;
}
.ne-column-filter > button.ne-column-filter__trigger:hover,
.ne-column-filter > button.ne-column-filter__trigger[aria-expanded="true"] {
color: var(--nx-text);
}
.ne-column-filter > button.ne-column-filter__trigger.is-active {
color: var(--nx-accent);
}
.ne-column-filter > button.ne-column-filter__trigger:focus-visible {
outline: 2px solid var(--nx-accent);
outline-offset: 2px;
}
.ne-column-filter__menu {
position: fixed;
z-index: 1200;
width: min(300px, calc(100vw - 16px));
max-height: calc(100dvh - 16px);
overflow-y: auto;
box-sizing: border-box;
padding: 10px;
border: 1px solid var(--nx-line-strong);
border-radius: 8px;
background: var(--nx-surface-2);
box-shadow: 0 12px 28px rgb(0 0 0 / 28%);
text-transform: none;
}
.ne-column-filter__select-all {
display: flex;
width: 100%;
margin: 8px 0 4px;
padding: 5px 2px 8px;
border-bottom: 1px solid var(--nx-line-soft);
align-items: center;
gap: 8px;
color: var(--nx-text);
cursor: pointer;
font-size: 12px;
font-weight: 500;
letter-spacing: 0;
text-transform: none;
}
.ne-column-filter__select-all input {
accent-color: var(--nx-accent-strong);
}
.ne-column-filter__options {
display: flex;
max-height: 220px;
flex-direction: column;
gap: 2px;
overflow: auto;
padding: 4px 0;
}
.ne-column-filter__option {
display: flex;
flex-shrink: 0;
min-height: 28px;
align-items: center;
gap: 8px;
overflow: hidden;
padding: 3px 2px;
color: var(--nx-text);
cursor: pointer;
font-size: 12px;
font-weight: 400;
letter-spacing: 0;
text-overflow: ellipsis;
text-transform: none;
white-space: nowrap;
}
.ne-column-filter__option input {
flex: 0 0 auto;
accent-color: var(--nx-accent-strong);
}
.ne-column-filter__option span {
overflow: hidden;
text-overflow: ellipsis;
}
.ne-column-filter__actions {
display: flex;
justify-content: flex-end;
gap: 6px;
margin-top: 8px;
}
.network-main .pt-list-table thead th {
background: var(--nx-surface-3);
color: var(--nx-text-muted);
@ -689,3 +809,23 @@
font-size: 12px;
color: var(--nx-text-soft);
}
/* Native checkboxes must not inherit text-input dimensions/padding. */
.ne-column-filter__menu input[type="checkbox"] {
appearance: auto;
flex: 0 0 16px;
width: 16px;
height: 16px;
min-height: 0;
margin: 0;
padding: 0;
cursor: pointer;
}
.ne-column-filter__menu input[type="checkbox"]:disabled {
cursor: wait;
}
.ne-column-filter__select-all:hover,
.ne-column-filter__option:hover {
background: var(--nx-surface-3);
}
.ne-column-filter__menu .button { flex-shrink: 0; }

View file

@ -48,6 +48,24 @@ export type CliTargetItem = {
ne_source?: string;
};
export type CliTargetFilterField =
| "source"
| "name"
| "ip"
| "vendor"
| "deviceType"
| "connectStatus";
export type CliTargetColumnFilter = {
mode: "include" | "exclude";
values: string[];
} | null;
export type CliTargetFilterOptions = {
field: CliTargetFilterField;
options: string[];
};
export type CliTargetListResponse = {
total: number;
page: number;

View file

@ -0,0 +1,2 @@
export type ColumnValueFilter = { mode: "include" | "exclude"; values: string[] } | null;
export type TableColumnFilters = Record<string, ColumnValueFilter>;