From 3c9e0efd2b80c4ab6996980d9bd3eecfe9600d17 Mon Sep 17 00:00:00 2001 From: oliver Date: Mon, 12 Oct 2026 23:36:00 +0800 Subject: [PATCH] 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. --- netx_api/cli_router.py | 52 ++- netx_api/cli_service.py | 233 +++++++++++++- netx_api/config_sync_router.py | 4 +- netx_api/config_sync_snapshots.py | 12 +- netx_api/lldp_collect_router.py | 6 +- netx_api/lldp_collect_service.py | 22 +- netx_api/table_filters.py | 55 ++++ netx_api/topology_discover_common.py | 18 +- netx_api/topology_fabric_nodes.py | 32 +- netx_api/topology_router.py | 5 + netx_api/ume_alarms_router.py | 12 + tests/conftest.py | 28 +- tests/test_table_column_filters.py | 112 +++++++ web/src/components/ui/ColumnFilterMenu.tsx | 251 +++++++++++++++ web/src/features/network/LldpLinksPage.tsx | 302 +++++------------- .../features/network/NetworkAlarmsPage.tsx | 162 +++------- .../features/network/NetworkConfigsPage.tsx | 114 ++----- .../features/network/NetworkDevicesPage.tsx | 224 +++++++------ .../features/network/TopologyClassifyPage.tsx | 121 ++----- web/src/hooks/useLocalTableColumnFilters.tsx | 25 ++ web/src/hooks/useTableColumnFilters.tsx | 34 ++ web/src/i18n/locales/en/networkDevices.ts | 12 + web/src/i18n/locales/en/ume.ts | 1 + web/src/i18n/locales/zh/networkDevices.ts | 12 + web/src/i18n/locales/zh/ume.ts | 1 + web/src/services/api/cli.ts | 36 ++- web/src/services/api/configSync.ts | 4 + web/src/services/api/tableFilters.ts | 13 + web/src/services/api/topology.ts | 11 +- web/src/styles/layout/network-shell.css | 140 ++++++++ web/src/types/cli.ts | 18 ++ web/src/types/tableFilters.ts | 2 + 32 files changed, 1429 insertions(+), 645 deletions(-) create mode 100644 netx_api/table_filters.py create mode 100644 tests/test_table_column_filters.py create mode 100644 web/src/components/ui/ColumnFilterMenu.tsx create mode 100644 web/src/hooks/useLocalTableColumnFilters.tsx create mode 100644 web/src/hooks/useTableColumnFilters.tsx create mode 100644 web/src/services/api/tableFilters.ts create mode 100644 web/src/types/tableFilters.ts diff --git a/netx_api/cli_router.py b/netx_api/cli_router.py index 065063c..d871850 100644 --- a/netx_api/cli_router.py +++ b/netx_api/cli_router.py @@ -1,6 +1,6 @@ from __future__ import annotations -from fastapi import APIRouter, Depends, Query +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session from .cli_schemas import ( @@ -15,12 +15,14 @@ from .cli_service import ( delete_cli_profile, get_cli_profile, get_ume_cli_override, + list_cli_target_filter_options, list_cli_profiles, list_cli_targets, set_default_cli_profile, update_cli_profile, upsert_ume_cli_override, ) +from .table_filters import TableFilterParams, table_filter_params from .db import get_db from .ne_connect import schedule_ume_connect_tests @@ -66,11 +68,57 @@ def api_delete_cli_profile(profile_id: str, db: Session = Depends(get_db)): def api_list_cli_targets( source: str = Query(default="all"), keyword: str | None = Query(default=None), + source_filter: list[str] | None = Query(default=None), + source_filter_mode: str | None = Query(default=None), + name_filter: list[str] | None = Query(default=None), + name_filter_mode: str | None = Query(default=None), + ip_filter: list[str] | None = Query(default=None), + ip_filter_mode: str | None = Query(default=None), + vendor_filter: list[str] | None = Query(default=None), + vendor_filter_mode: str | None = Query(default=None), + device_type_filter: list[str] | None = Query(default=None), + device_type_filter_mode: str | None = Query(default=None), + connect_status_filter: list[str] | None = Query(default=None), + connect_status_filter_mode: str | None = Query(default=None), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=500), + table_filters: TableFilterParams = Depends(table_filter_params), db: Session = Depends(get_db), ): - return list_cli_targets(db, source=source, keyword=keyword, page=page, page_size=page_size) + if table_filters.field: + return list_cli_target_filter_options(db, field=table_filters.field, source=source, keyword=keyword) + filters = table_filters.filters + if set(filters) - {"source", "name", "ip", "vendor", "deviceType", "connectStatus"}: + raise HTTPException(status_code=400, detail="invalid_filter_field") + return list_cli_targets( + db, + source=source, + keyword=keyword, + source_filter=filters.get("source", {}).get("values", source_filter), + source_filter_mode=filters.get("source", {}).get("mode", source_filter_mode), + name_filter=filters.get("name", {}).get("values", name_filter), + name_filter_mode=filters.get("name", {}).get("mode", name_filter_mode), + ip_filter=filters.get("ip", {}).get("values", ip_filter), + ip_filter_mode=filters.get("ip", {}).get("mode", ip_filter_mode), + vendor_filter=filters.get("vendor", {}).get("values", vendor_filter), + vendor_filter_mode=filters.get("vendor", {}).get("mode", vendor_filter_mode), + device_type_filter=filters.get("deviceType", {}).get("values", device_type_filter), + device_type_filter_mode=filters.get("deviceType", {}).get("mode", device_type_filter_mode), + connect_status_filter=filters.get("connectStatus", {}).get("values", connect_status_filter), + connect_status_filter_mode=filters.get("connectStatus", {}).get("mode", connect_status_filter_mode), + page=page, + page_size=page_size, + ) + + +@router.get("/targets/filter-options") +def api_list_cli_target_filter_options( + field: str = Query(), + source: str = Query(default="inventory"), + keyword: str | None = Query(default=None), + db: Session = Depends(get_db), +): + return list_cli_target_filter_options(db, field=field, source=source, keyword=keyword) @router.get("/ume-overrides/{ume_ne_id}") diff --git a/netx_api/cli_service.py b/netx_api/cli_service.py index 82707c6..439db50 100644 --- a/netx_api/cli_service.py +++ b/netx_api/cli_service.py @@ -4,6 +4,7 @@ from datetime import datetime from typing import Any from fastapi import HTTPException +from sqlalchemy import func from sqlalchemy.orm import Session from .cli_schemas import ( @@ -279,19 +280,66 @@ def list_cli_targets( *, source: str = "all", keyword: str | None = None, + source_filter: list[str] | None = None, + source_filter_mode: str | None = None, + name_filter: list[str] | None = None, + name_filter_mode: str | None = None, + ip_filter: list[str] | None = None, + ip_filter_mode: str | None = None, + vendor_filter: list[str] | None = None, + vendor_filter_mode: str | None = None, + device_type_filter: list[str] | None = None, + device_type_filter_mode: str | None = None, + connect_status_filter: list[str] | None = None, + connect_status_filter_mode: str | None = None, page: int = 1, page_size: int = 50, ) -> dict[str, Any]: from .device_types import WEBCRT_NE_SOURCE src = str(source or "all").strip().lower() - if src not in ("managed", "ume", "webcrt", "all"): + if src not in ("managed", "ume", "webcrt", "inventory", "all"): raise HTTPException(status_code=400, detail="invalid_source") ready = cli_profile_ready(db) page = max(1, int(page or 1)) page_size = max(1, min(500, int(page_size or 50))) offset = (page - 1) * page_size kw = str(keyword or "").strip() + column_filters: dict[str, list[str] | None] = { + "source": source_filter, + "name": name_filter, + "ip": ip_filter, + "vendor": vendor_filter, + "device_type": device_type_filter, + "connect_status": connect_status_filter, + } + filter_modes = { + "source": str(source_filter_mode or ""), + "name": str(name_filter_mode or ""), + "ip": str(ip_filter_mode or ""), + "vendor": str(vendor_filter_mode or ""), + "device_type": str(device_type_filter_mode or ""), + "connect_status": str(connect_status_filter_mode or ""), + } + + def _source_allowed(row_source: str) -> bool: + mode = filter_modes["source"] + values = column_filters["source"] or [] + if mode == "include": + return row_source in values + if mode == "exclude": + return row_source not in values + return True + + def _apply_value_filter(stmt: Any, field: str, expression: Any) -> Any: + mode = filter_modes[field] + if mode not in ("include", "exclude"): + return stmt + values = column_filters[field] or [] + normalized = func.coalesce(expression, "") + if mode == "include": + return stmt.filter(normalized.in_(values)) if values else stmt.filter(False) + return stmt.filter(~normalized.in_(values)) if values else stmt default_profile = get_default_profile(db) default_hop = bool(getattr(default_profile, "hop_enabled", False)) if default_profile else False @@ -351,7 +399,8 @@ def list_cli_targets( return stmt like = f"%{kw}%" return stmt.filter( - ManagedNE.name.ilike(like) + ManagedNE.id.ilike(like) + | ManagedNE.name.ilike(like) | ManagedNE.ip_address.ilike(like) | ManagedNE.username.ilike(like) | ManagedNE.tags.ilike(like) @@ -362,16 +411,42 @@ def list_cli_targets( def _inventory_managed_query(): # Asset inventory for WebCRT: exclude Quick-Connect (source=webcrt) rows. stmt = db.query(ManagedNE).filter(ManagedNE.source != WEBCRT_NE_SOURCE) - return _apply_kw(stmt).order_by(ManagedNE.updated_at.desc()) + stmt = _apply_kw(stmt) + if not _source_allowed("managed"): + stmt = stmt.filter(False) + stmt = _apply_value_filter( + stmt, + "name", + func.coalesce(func.nullif(ManagedNE.name, ""), ManagedNE.ip_address), + ) + stmt = _apply_value_filter(stmt, "ip", ManagedNE.ip_address) + stmt = _apply_value_filter(stmt, "vendor", ManagedNE.vendor) + stmt = _apply_value_filter(stmt, "device_type", ManagedNE.device_type) + stmt = _apply_value_filter(stmt, "connect_status", ManagedNE.connect_status) + return stmt.order_by(ManagedNE.updated_at.desc()) def _webcrt_query(): stmt = db.query(ManagedNE).filter(ManagedNE.source == WEBCRT_NE_SOURCE) - return _apply_kw(stmt).order_by(ManagedNE.updated_at.desc()) + stmt = _apply_kw(stmt) + if not _source_allowed("webcrt"): + stmt = stmt.filter(False) + stmt = _apply_value_filter( + stmt, + "name", + func.coalesce(func.nullif(ManagedNE.name, ""), ManagedNE.ip_address), + ) + stmt = _apply_value_filter(stmt, "ip", ManagedNE.ip_address) + stmt = _apply_value_filter(stmt, "vendor", ManagedNE.vendor) + stmt = _apply_value_filter(stmt, "device_type", ManagedNE.device_type) + stmt = _apply_value_filter(stmt, "connect_status", ManagedNE.connect_status) + return stmt.order_by(ManagedNE.updated_at.desc()) def _ume_query(): stmt = db.query(UmeInventoryNE, UmeCliOverride).outerjoin( UmeCliOverride, UmeInventoryNE.ne_id == UmeCliOverride.ume_ne_id ) + if not _source_allowed("ume"): + stmt = stmt.filter(False) if kw: like = f"%{kw}%" stmt = stmt.filter( @@ -381,8 +456,46 @@ def list_cli_targets( | UmeInventoryNE.ip_address.ilike(like) | UmeInventoryNE.host_name.ilike(like) ) + ume_name = func.coalesce( + func.nullif(UmeInventoryNE.user_label, ""), + func.nullif(UmeInventoryNE.ne_name, ""), + func.nullif(UmeInventoryNE.host_name, ""), + func.nullif(UmeInventoryNE.ip_address, ""), + UmeInventoryNE.ne_id, + ) + stmt = _apply_value_filter(stmt, "name", ume_name) + stmt = _apply_value_filter(stmt, "ip", UmeInventoryNE.ip_address) + stmt = _apply_value_filter(stmt, "vendor", UmeInventoryNE.vendor) + stmt = _apply_value_filter(stmt, "device_type", UmeInventoryNE.ne_type) + stmt = _apply_value_filter( + stmt, + "connect_status", + func.coalesce(UmeCliOverride.connect_status, "unknown"), + ) return stmt.order_by(UmeInventoryNE.ne_id.asc()) + if src == "inventory": + # Network Management inventory includes managed assets and UME inventory, + # but not transient WebCRT quick-connect sessions. + mq = _inventory_managed_query() + uq = _ume_query() + m_total = int(mq.count()) + u_total = int(uq.count()) + total = m_total + u_total + items: list[dict[str, Any]] = [] + + if offset < m_total: + take = min(page_size, m_total - offset) + items.extend(_managed_item(row, list_source="managed") for row in mq.offset(offset).limit(take).all()) + remaining = page_size - take + if remaining > 0: + items.extend(_ume_item(inv, ov) for inv, ov in uq.offset(0).limit(remaining).all()) + else: + ume_offset = max(0, offset - m_total) + items.extend(_ume_item(inv, ov) for inv, ov in uq.offset(ume_offset).limit(page_size).all()) + + return {"items": items, "total": total, "page": page, "page_size": page_size} + if src == "managed": mq = _inventory_managed_query() total = int(mq.count()) @@ -443,6 +556,118 @@ def list_cli_targets( return {"items": items, "total": total, "page": page, "page_size": page_size} +def list_cli_target_filter_options( + db: Session, + *, + field: str, + source: str = "inventory", + keyword: str | None = None, +) -> dict[str, Any]: + from .device_types import WEBCRT_NE_SOURCE + + field_map = { + "source": "source", + "name": "name", + "ip": "ip", + "vendor": "vendor", + "deviceType": "device_type", + "connectStatus": "connect_status", + } + if field not in field_map: + raise HTTPException(status_code=400, detail="invalid_filter_field") + src = str(source or "inventory").strip().lower() + if src not in ("managed", "ume", "webcrt", "inventory", "all"): + raise HTTPException(status_code=400, detail="invalid_source") + + include_managed = src in ("managed", "inventory", "all") + include_webcrt = src in ("webcrt", "all") + include_ume = src in ("ume", "inventory", "all") + kw = str(keyword or "").strip() + + managed = db.query(ManagedNE) + if src in ("managed", "inventory"): + managed = managed.filter(ManagedNE.source != WEBCRT_NE_SOURCE) + elif src == "webcrt": + managed = managed.filter(ManagedNE.source == WEBCRT_NE_SOURCE) + elif src == "all": + managed = managed.filter(ManagedNE.source != WEBCRT_NE_SOURCE) + ume = db.query(UmeInventoryNE) + ume_status = db.query(UmeInventoryNE, UmeCliOverride).outerjoin( + UmeCliOverride, UmeInventoryNE.ne_id == UmeCliOverride.ume_ne_id + ) + webcrt = db.query(ManagedNE).filter(ManagedNE.source == WEBCRT_NE_SOURCE) + + def _apply_managed_keyword(query: Any) -> Any: + if not kw: + return query + like = f"%{kw}%" + return query.filter( + ManagedNE.id.ilike(like) + | ManagedNE.name.ilike(like) + | ManagedNE.ip_address.ilike(like) + | ManagedNE.username.ilike(like) + | ManagedNE.tags.ilike(like) + | ManagedNE.vendor.ilike(like) + | ManagedNE.device_type.ilike(like) + ) + + def _apply_ume_keyword(query: Any) -> Any: + if not kw: + return query + like = f"%{kw}%" + return query.filter( + UmeInventoryNE.ne_id.ilike(like) + | UmeInventoryNE.ne_name.ilike(like) + | UmeInventoryNE.user_label.ilike(like) + | UmeInventoryNE.ip_address.ilike(like) + | UmeInventoryNE.host_name.ilike(like) + ) + + managed = _apply_managed_keyword(managed) + webcrt = _apply_managed_keyword(webcrt) + ume = _apply_ume_keyword(ume) + ume_status = _apply_ume_keyword(ume_status) + + if field == "source": + values: set[str] = set() + if include_managed and managed.count(): + values.add("managed") + if include_webcrt and webcrt.count(): + values.add("webcrt") + if include_ume and ume.count(): + values.add("ume") + else: + managed_expr = { + "name": func.coalesce(func.nullif(ManagedNE.name, ""), ManagedNE.ip_address), + "ip": ManagedNE.ip_address, + "vendor": ManagedNE.vendor, + "device_type": ManagedNE.device_type, + "connect_status": ManagedNE.connect_status, + }.get(field_map[field]) + ume_expr = { + "name": func.coalesce( + func.nullif(UmeInventoryNE.user_label, ""), + func.nullif(UmeInventoryNE.ne_name, ""), + func.nullif(UmeInventoryNE.host_name, ""), + func.nullif(UmeInventoryNE.ip_address, ""), + UmeInventoryNE.ne_id, + ), + "ip": UmeInventoryNE.ip_address, + "vendor": UmeInventoryNE.vendor, + "device_type": UmeInventoryNE.ne_type, + "connect_status": func.coalesce(UmeCliOverride.connect_status, "unknown"), + }.get(field_map[field]) + values = set() + if include_managed and managed_expr is not None: + values.update(str(row[0] or "") for row in managed.with_entities(managed_expr).distinct().all()) + if include_webcrt and managed_expr is not None: + values.update(str(row[0] or "") for row in webcrt.with_entities(managed_expr).distinct().all()) + if include_ume and ume_expr is not None: + values.update(str(row[0] or "") for row in ume_status.with_entities(ume_expr).distinct().all()) + + return {"field": field, "options": sorted(values, key=str.casefold)} + + def cli_meta(db: Session) -> dict[str, Any]: return { "credentials_configured": credentials_configured(), diff --git a/netx_api/config_sync_router.py b/netx_api/config_sync_router.py index 375f241..62a9a81 100644 --- a/netx_api/config_sync_router.py +++ b/netx_api/config_sync_router.py @@ -27,6 +27,7 @@ from .config_sync_service import ( stop_cycle, update_policy, ) +from .table_filters import TableFilterParams, table_filter_params from .db import get_db router = APIRouter(prefix="/v1/config-sync", tags=["config-sync"]) @@ -164,10 +165,11 @@ def api_list_snapshots( keyword: str = Query(default=""), source: str = Query(default=""), vendor: str = Query(default=""), + table_filters: TableFilterParams = Depends(table_filter_params), db: Session = Depends(get_db), ): return list_snapshots( - db, page=page, page_size=page_size, keyword=keyword, source=source, vendor=vendor + db, page=page, page_size=page_size, keyword=keyword, source=source, vendor=vendor, table_filters=table_filters ) diff --git a/netx_api/config_sync_snapshots.py b/netx_api/config_sync_snapshots.py index 97299af..ca8e582 100644 --- a/netx_api/config_sync_snapshots.py +++ b/netx_api/config_sync_snapshots.py @@ -7,7 +7,7 @@ import zipfile from typing import Any from fastapi import HTTPException -from sqlalchemy import or_ +from sqlalchemy import func, or_ from sqlalchemy.orm import Session from .config_sync_codec import decompress_text @@ -16,6 +16,7 @@ from .config_sync_schemas import ( NeConfigSnapshotDetailOut, NeConfigSnapshotMetaOut, ) +from .table_filters import TableFilterParams, filter_query from .models import NeConfigHistory, NeConfigSnapshot def _snap_meta(row: NeConfigSnapshot) -> NeConfigSnapshotMetaOut: @@ -48,6 +49,7 @@ def list_snapshots( keyword: str = "", source: str = "", vendor: str = "", + table_filters: TableFilterParams | None = None, ) -> dict[str, Any]: q = db.query(NeConfigSnapshot) src = str(source or "").strip().lower() @@ -66,6 +68,14 @@ def list_snapshots( NeConfigSnapshot.target_id.ilike(like), ) ) + q, options = filter_query(q, { + "name": func.coalesce(func.nullif(NeConfigSnapshot.ne_name, ""), NeConfigSnapshot.target_id), + "ip": NeConfigSnapshot.ne_ip, "vendor": NeConfigSnapshot.vendor, + "source": NeConfigSnapshot.source, "size": NeConfigSnapshot.plain_size, + "collected": NeConfigSnapshot.collected_at, + }, table_filters) + if options is not None: + return options total = int(q.count()) rows = q.order_by(NeConfigSnapshot.collected_at.desc()).offset((page - 1) * page_size).limit(page_size).all() return {"total": total, "page": page, "page_size": page_size, "items": [_snap_meta(r) for r in rows]} diff --git a/netx_api/lldp_collect_router.py b/netx_api/lldp_collect_router.py index addfcf2..4844e47 100644 --- a/netx_api/lldp_collect_router.py +++ b/netx_api/lldp_collect_router.py @@ -7,6 +7,7 @@ from typing import Any from fastapi import APIRouter, Depends, Query from sqlalchemy.orm import Session +from .table_filters import TableFilterParams, table_filter_params from .db import get_db from .lldp_collect_schemas import LldpCollectPolicyUpdate, LldpCollectStartBody from .lldp_collect_service import ( @@ -69,9 +70,10 @@ def api_list_jobs( page_size: int = Query(default=20, ge=1, le=100), status: str = Query(default=""), keyword: str = Query(default=""), + table_filters: TableFilterParams = Depends(table_filter_params), db: Session = Depends(get_db), ) -> dict[str, Any]: - return list_jobs(db, page=page, page_size=page_size, status=status, keyword=keyword) + return list_jobs(db, page=page, page_size=page_size, status=status, keyword=keyword, table_filters=table_filters) @router.get("/jobs/{job_id}") @@ -81,6 +83,7 @@ def api_get_job( page_size: int = Query(default=20, ge=1, le=100), status: str = Query(default=""), keyword: str = Query(default=""), + table_filters: TableFilterParams = Depends(table_filter_params), db: Session = Depends(get_db), ) -> dict[str, Any]: return get_job_detail( @@ -90,4 +93,5 @@ def api_get_job( page_size=page_size, item_status=status, item_keyword=keyword, + table_filters=table_filters, ) diff --git a/netx_api/lldp_collect_service.py b/netx_api/lldp_collect_service.py index e5eb63b..4cd40f8 100644 --- a/netx_api/lldp_collect_service.py +++ b/netx_api/lldp_collect_service.py @@ -5,9 +5,11 @@ from __future__ import annotations from datetime import datetime, timedelta from fastapi import HTTPException -from sqlalchemy import or_ +from sqlalchemy import String, cast, or_ from sqlalchemy.orm import Session +from .table_filters import TableFilterParams, filter_query +from .topology_discover_common import discover_item_expressions from .lldp_collect_schemas import ( LldpCollectDashboardOut, LldpCollectJobSummary, @@ -488,6 +490,7 @@ def list_jobs( page_size: int = 20, status: str = "", keyword: str = "", + table_filters: TableFilterParams | None = None, ) -> dict: page = max(1, int(page or 1)) page_size = max(1, min(100, int(page_size or 20))) @@ -506,6 +509,15 @@ def list_jobs( TopoDiscoverJob.trigger_mode.ilike(like), ) ) + job = TopoDiscoverJob + q, options = filter_query(q, { + "id": job.id, "trigger": job.trigger_mode, "scope": job.scope, "status": job.status, + "progress": cast(job.done, String) + "/" + cast(job.total, String), + "edges": cast(job.edges_added, String) + "/" + cast(job.edges_updated, String), + "missingDelta": job.edges_stale, "started": job.started_at, "ended": job.ended_at, + }, table_filters) + if options is not None: + return options q = q.order_by(TopoDiscoverJob.created_at.desc()) total = int(q.count()) rows = q.offset((page - 1) * page_size).limit(page_size).all() @@ -532,7 +544,14 @@ def get_job_detail( page_size: int | None = None, item_status: str = "", item_keyword: str = "", + table_filters: TableFilterParams | None = None, ) -> dict: + if table_filters and table_filters.field: + if db.get(TopoDiscoverJob, job_id) is None: + raise HTTPException(status_code=404, detail="discover_job_not_found") + q = db.query(TopoDiscoverJobItem).filter(TopoDiscoverJobItem.job_id == job_id) + _, options = filter_query(q, discover_item_expressions(), table_filters) + return options return get_discover_job( db, job_id, @@ -540,4 +559,5 @@ def get_job_detail( page_size=page_size, item_status=item_status, item_keyword=item_keyword, + table_filters=table_filters, ).model_dump() diff --git a/netx_api/table_filters.py b/netx_api/table_filters.py new file mode 100644 index 0000000..c9bbd55 --- /dev/null +++ b/netx_api/table_filters.py @@ -0,0 +1,55 @@ +"""Shared exact-value column filters, applied before pagination.""" +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from typing import Any + +from fastapi import HTTPException, Query +from sqlalchemy import String, cast, func + + +@dataclass +class TableFilterParams: + filters: dict[str, dict[str, Any]] = field(default_factory=dict) + field: str = "" + + +def table_filter_params( + column_filters: str = Query(default=""), + filter_field: str = Query(default=""), +) -> TableFilterParams: + try: + filters = json.loads(column_filters) if column_filters else {} + if not isinstance(filters, dict): + raise ValueError() + for value in filters.values(): + if not isinstance(value, dict) or value.get("mode") not in ("include", "exclude"): + raise ValueError() + values = value.get("values") + if not isinstance(values, list) or any(not isinstance(v, str) for v in values): + raise ValueError() + except (ValueError, TypeError): + raise HTTPException(status_code=400, detail="invalid_column_filters") from None + return TableFilterParams(filters, filter_field) + + +def filter_query(query: Any, expressions: dict[str, Any], params: TableFilterParams | None): + """Return the filtered query and, when requested, a distinct-value response.""" + params = params or TableFilterParams() + if (set(params.filters) | ({params.field} if params.field else set())) - expressions.keys(): + raise HTTPException(status_code=400, detail="invalid_filter_field") + for key, value in params.filters.items(): + if key == params.field: + continue + expression = func.coalesce(cast(expressions[key], String), "") + values = value["values"] + if value["mode"] == "include": + query = query.filter(expression.in_(values)) if values else query.filter(False) + elif values: + query = query.filter(~expression.in_(values)) + if params.field: + expression = func.coalesce(cast(expressions[params.field], String), "") + rows = query.order_by(None).with_entities(expression).distinct().all() + return query, {"field": params.field, "options": sorted({str(row[0]) for row in rows})} + return query, None diff --git a/netx_api/topology_discover_common.py b/netx_api/topology_discover_common.py index 9558318..cb2adb0 100644 --- a/netx_api/topology_discover_common.py +++ b/netx_api/topology_discover_common.py @@ -4,9 +4,10 @@ from __future__ import annotations from typing import Any from fastapi import HTTPException -from sqlalchemy import or_ +from sqlalchemy import String, case, cast, func, or_ from sqlalchemy.orm import Session +from .table_filters import TableFilterParams, filter_query from .cli_resolve import get_default_profile, infer_device_type_vendor from .device_types import is_placeholder_ne_source from .models import ManagedNE, TopoDiscoverJob, TopoDiscoverJobItem, UmeInventoryNE @@ -42,6 +43,17 @@ def _managed_target_dict(ne: ManagedNE) -> dict[str, str]: } +def discover_item_expressions() -> dict[str, Any]: + item = TopoDiscoverJobItem + failed = or_(item.ok.is_(False), item.parser_stub.is_(True), item.error.in_(["parser_stub", "empty_cli_output", "vendor_or_device_type_required"])) + return { + "name": func.coalesce(func.nullif(item.ne_name, ""), item.ne_id), "ip": item.ne_ip, + "status": case((failed, "fail"), (item.unmatched_count > 0, "warn"), else_="ok"), + "neighbors": item.neighbors, "error": item.error, + "edges": cast(item.edges_added, String) + "/" + cast(item.edges_updated, String), + } + + def _job_out( db: Session, job: TopoDiscoverJob, @@ -51,6 +63,7 @@ def _job_out( page_size: int | None = None, item_status: str = "", item_keyword: str = "", + table_filters: TableFilterParams | None = None, ) -> FabricDiscoverJobOut: items_out: list[FabricDiscoverJobItemOut] = [] items_total = 0 @@ -93,6 +106,7 @@ def _job_out( TopoDiscoverJobItem.error.ilike(like), ) ) + q, _ = filter_query(q, discover_item_expressions(), table_filters) items_total = int(q.count()) if page is not None and page_size is not None: items_page = max(1, int(page or 1)) @@ -157,6 +171,7 @@ def get_discover_job( page_size: int | None = None, item_status: str = "", item_keyword: str = "", + table_filters: TableFilterParams | None = None, ) -> FabricDiscoverJobOut: job = db.get(TopoDiscoverJob, str(job_id or "").strip()) if job is None: @@ -168,6 +183,7 @@ def get_discover_job( page_size=page_size, item_status=item_status, item_keyword=item_keyword, + table_filters=table_filters, ) diff --git a/netx_api/topology_fabric_nodes.py b/netx_api/topology_fabric_nodes.py index a42f946..ba37251 100644 --- a/netx_api/topology_fabric_nodes.py +++ b/netx_api/topology_fabric_nodes.py @@ -8,10 +8,11 @@ from typing import Any from uuid import uuid4 from fastapi import HTTPException -from sqlalchemy import and_, func, or_, text +from sqlalchemy import and_, case, func, or_, text from sqlalchemy.exc import IntegrityError from sqlalchemy.orm import Session +from .table_filters import TableFilterParams, filter_query from .cli_resolve import get_default_profile, infer_device_type_vendor from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE from .models import ( @@ -275,6 +276,7 @@ def list_fabric_nodes( region_folder_id: str = "", unmatched: str = "", link_status: str = "", + table_filters: TableFilterParams | None = None, page: int = 1, page_size: int = PAGE_DEFAULT, ) -> dict[str, Any]: @@ -375,6 +377,16 @@ def list_fabric_nodes( and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""), and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""), ) + has_managed = func.coalesce(TopoFabricNode.managed_ne_id, "") != "" + has_ume = func.coalesce(TopoFabricNode.ume_ne_id, "") != "" + q, options = filter_query(q, { + "name": func.coalesce(func.nullif(TopoFabricNode.name, ""), TopoFabricNode.id), + "ip": TopoFabricNode.ip, "level": TopoFabricNode.level, + "region": TopoFabricNode.region_folder_id, + "link": case((and_(has_managed, has_ume), "both"), (has_managed, "managed"), (has_ume, "ume"), else_="orphaned"), + }, table_filters) + if options is not None: + return options total = int(q.count()) rows = ( q.order_by(TopoFabricNode.name.asc()) @@ -399,6 +411,7 @@ def list_fabric_edges( status: str = "", source: str = "", keyword: str = "", + table_filters: TableFilterParams | None = None, page: int = 1, page_size: int = PAGE_DEFAULT, ) -> dict[str, Any]: @@ -438,6 +451,23 @@ def list_fabric_edges( TopoFabricEdge.b_node_id.in_(matched_ids), ) ) + from sqlalchemy.orm import aliased + a = aliased(TopoFabricNode) + b = aliased(TopoFabricNode) + q = q.outerjoin(a, TopoFabricEdge.a_node_id == a.id).outerjoin(b, TopoFabricEdge.b_node_id == b.id) + status_expr = case((TopoFabricEdge.status == "stale", "missing"), else_=TopoFabricEdge.status) + miss = func.coalesce(TopoFabricEdge.attrs["miss_count"].as_integer(), 0) + q, options = filter_query(q, { + "status": status_expr, + "aSide": func.coalesce(func.nullif(a.name, ""), func.nullif(a.ip, ""), func.substr(TopoFabricEdge.a_node_id, 1, 8)), + "bSide": func.coalesce(func.nullif(b.name, ""), func.nullif(b.ip, ""), func.substr(TopoFabricEdge.b_node_id, 1, 8)), + "aPort": TopoFabricEdge.a_port, "bPort": TopoFabricEdge.b_port, + "source": case((TopoFabricEdge.source == "stale", "lldp"), else_=func.coalesce(func.nullif(TopoFabricEdge.source, ""), "lldp")), + "lastSeen": TopoFabricEdge.last_seen_at, + "missCount": case((miss != 0, miss), (status_expr == "missing", 1), else_=0), + }, table_filters) + if options is not None: + return options total = int(q.count()) rows = ( q.order_by(TopoFabricEdge.updated_at.desc()) diff --git a/netx_api/topology_router.py b/netx_api/topology_router.py index 7769be0..ef79c06 100644 --- a/netx_api/topology_router.py +++ b/netx_api/topology_router.py @@ -7,6 +7,7 @@ from typing import Any from fastapi import APIRouter, Body, Depends, HTTPException, Query from sqlalchemy.orm import Session +from .table_filters import TableFilterParams, table_filter_params from .db import get_db from .topology_classify import ( apply_classify, @@ -107,6 +108,7 @@ def api_fabric_nodes( ), page: int = Query(default=1, ge=1), page_size: int = Query(default=100, ge=1, le=2000), + table_filters: TableFilterParams = Depends(table_filter_params), db: Session = Depends(get_db), ) -> dict[str, Any]: return list_fabric_nodes( @@ -118,6 +120,7 @@ def api_fabric_nodes( region_folder_id=region_folder_id, unmatched=unmatched, link_status=link_status, + table_filters=table_filters, page=page, page_size=page_size, ) @@ -132,6 +135,7 @@ def api_fabric_edges( keyword: str = "", page: int = Query(default=1, ge=1), page_size: int = Query(default=100, ge=1, le=2000), + table_filters: TableFilterParams = Depends(table_filter_params), db: Session = Depends(get_db), ) -> dict[str, Any]: return list_fabric_edges( @@ -141,6 +145,7 @@ def api_fabric_edges( status=status, source=source, keyword=keyword, + table_filters=table_filters, page=page, page_size=page_size, ) diff --git a/netx_api/ume_alarms_router.py b/netx_api/ume_alarms_router.py index 472d37b..8f94be5 100644 --- a/netx_api/ume_alarms_router.py +++ b/netx_api/ume_alarms_router.py @@ -10,6 +10,8 @@ from sqlalchemy import func, or_ from sqlalchemy.orm import Session from .config import settings +from .table_filters import TableFilterParams, filter_query +from .table_filters import table_filter_params from .db import get_db from .key_alert_config import ( get_key_alert_monitor_config, @@ -78,6 +80,7 @@ def ume_list_alarms( time_to: str | None = Query(default=None, description="Filter by last_seen_at <="), page: int = Query(default=1, ge=1), page_size: int = Query(default=50, ge=1, le=500), + table_filters: TableFilterParams = Depends(table_filter_params), db: Session = Depends(get_db), ) -> dict[str, Any]: stmt = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin( @@ -113,6 +116,15 @@ def ume_list_alarms( stmt = stmt.filter(UmeAlarmCurrent.last_seen_at >= dt_from.replace(tzinfo=None)) if dt_to: stmt = stmt.filter(UmeAlarmCurrent.last_seen_at <= dt_to.replace(tzinfo=None)) + stmt, options = filter_query(stmt, { + "time": UmeAlarmCurrent.time_created, "severity": UmeAlarmCurrent.perceived_severity, + "neId": UmeAlarmCurrent.ne_id, + "hostName": func.coalesce(func.nullif(func.trim(UmeAlarmCurrent.host_name), ""), func.trim(UmeInventoryNE.host_name)), + "neType": UmeInventoryNE.ne_type, "cause": UmeAlarmCurrent.native_probable_cause, + "cleared": UmeAlarmCurrent.is_cleared, + }, table_filters) + if options is not None: + return options total = int(stmt.count()) rows = ( stmt.order_by( diff --git a/tests/conftest.py b/tests/conftest.py index 6b2d017..9014c70 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,33 @@ -"""Shared test hygiene.""" +"""Shared test hygiene: never connect the test suite to the application database.""" +import os +from pathlib import Path +from tempfile import TemporaryDirectory + import pytest +# Set this before any test imports netx_api.config/db. Several older unittest +# cases use the global SessionLocal and delete entire tables in setUp. +_TEST_STATE = TemporaryDirectory(prefix="netx-tests-", ignore_cleanup_errors=True) +_TEST_DATABASE_URL = f"sqlite:///{(Path(_TEST_STATE.name) / 'netx.sqlite').as_posix()}" +os.environ["NETX_DATABASE_URL"] = _TEST_DATABASE_URL +os.environ["NETX_CREDENTIAL_SECRET_FILE"] = str(Path(_TEST_STATE.name) / "credential_secret") + + +def pytest_sessionstart(session): + from netx_api.db import engine + + if str(engine.url) != _TEST_DATABASE_URL: + raise pytest.UsageError("NetX tests require the isolated temporary database") + + +def pytest_sessionfinish(session, exitstatus): + from netx_api.db import engine + + engine.dispose() + _TEST_STATE.cleanup() + + @pytest.fixture(autouse=True) def _fresh_baseline_row_cache(): """Test databases reuse batch ids; the process-wide baseline cache must not leak between tests.""" diff --git a/tests/test_table_column_filters.py b/tests/test_table_column_filters.py new file mode 100644 index 0000000..6f69ea5 --- /dev/null +++ b/tests/test_table_column_filters.py @@ -0,0 +1,112 @@ +"""Column filters must cover the full dataset and run before pagination.""" +import json +from unittest.mock import patch + +import pytest +from fastapi import FastAPI +from fastapi.testclient import TestClient +from sqlalchemy import create_engine +from sqlalchemy.orm import Session +from sqlalchemy.pool import StaticPool + +from netx_api.cli_router import router as cli_router +from netx_api.config_sync_router import router as config_router +from netx_api.db import Base, get_db +from netx_api.lldp_collect_router import router as lldp_router +from netx_api.models import ( + ManagedNE, NeConfigSnapshot, TopoDiscoverJob, TopoDiscoverJobItem, + TopoFabricEdge, TopoFabricNode, UmeAlarmCurrent, UmeInventoryNE, +) +from netx_api.topology_router import router as topology_router +from netx_api.ume_alarms_router import router as alarm_router + + +@pytest.fixture +def client(): + engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool) + Base.metadata.create_all(engine) + with Session(engine) as db: + db.add_all([ + ManagedNE(id="m1", name="Device A", ip_address="10.0.0.1", vendor="Huawei", source="manual"), + ManagedNE(id="m2", name="Device B", ip_address="10.0.0.2", vendor="Cisco", source="manual"), + UmeInventoryNE(ne_id="u1", host_name="Fallback Host", ne_type="Router"), + NeConfigSnapshot(source="managed", target_id="m1", ne_name="Device A", vendor="Huawei", plain_size=100), + NeConfigSnapshot(source="ume", target_id="u1", ne_name="Device B", vendor="Cisco", plain_size=200), + UmeAlarmCurrent(alarm_key="a1", ne_id="u1", host_name="", perceived_severity="major", is_cleared="false"), + UmeAlarmCurrent(alarm_key="a2", host_name="Other Host", perceived_severity="minor", is_cleared="true"), + TopoFabricNode(id="n1", name="Device A", ip="10.0.0.1", managed_ne_id="m1", level=1), + TopoFabricNode(id="n2", name="Device B", ip="10.0.0.2", level=None), + TopoFabricEdge(id="e1", a_node_id="n1", b_node_id="n2", a_port="p1", status="stale", attrs={"miss_count": 2}), + TopoFabricEdge(id="e2", a_node_id="n1", b_node_id="n2", a_port="p2", status="active"), + TopoDiscoverJob(id="j1", status="done", total=2, done=2), + TopoDiscoverJob(id="j2", status="failed", total=1, done=1), + TopoDiscoverJobItem(id="i1", job_id="j1", ne_name="Device A", ok=True, neighbors=2), + TopoDiscoverJobItem(id="i2", job_id="j1", ne_name="Device B", ok=True, error="empty_cli_output"), + ]) + db.commit() + app = FastAPI() + for router in (cli_router, config_router, topology_router, lldp_router, alarm_router): + app.include_router(router) + + def database(): + with Session(engine) as db: + yield db + + app.dependency_overrides[get_db] = database + with patch("netx_api.auth_deps.settings.auth_enabled", False), TestClient(app) as test_client: + yield test_client + engine.dispose() + + +CASES = [ + ("/v1/cli/targets", {"source": "inventory"}, "name", "Device B", "items", "total"), + ("/v1/config-sync/snapshots", {}, "vendor", "Cisco", "items", "total"), + ("/v1/ume/alarms", {}, "hostName", "Fallback Host", "items", "total"), + ("/v1/topology/fabric/nodes", {}, "level", "", "items", "total"), + ("/v1/topology/fabric/edges", {}, "status", "missing", "items", "total"), + ("/v1/topology/lldp-collect/jobs", {}, "status", "failed", "items", "total"), + ("/v1/topology/lldp-collect/jobs/j1", {}, "status", "fail", "items", "items_total"), +] + + +@pytest.mark.parametrize("path,scope,field,value,items_key,total_key", CASES) +def test_options_and_selection_before_pagination(client, path, scope, field, value, items_key, total_key): + options = client.get(path, params={**scope, "filter_field": field, "page_size": 1}) + assert options.status_code == 200, options.text + assert value in options.json()["options"] + assert len(options.json()["options"]) >= 2 + for mode, selected, expected_total in [("include", [value], 1), ("include", [], 0), ("exclude", [], len(client.get(path, params=scope).json()[items_key]))]: + response = client.get(path, params={**scope, "page_size": 1, "column_filters": json.dumps({field: {"mode": mode, "values": selected}})}) + assert response.status_code == 200, response.text + assert response.json()[total_key] == expected_total + assert len(response.json()[items_key]) == min(expected_total, 1) + # Exclusion is the complement of inclusion, including blank values. + all_total = client.get(path, params=scope).json()[total_key] + excluded = client.get(path, params={**scope, "column_filters": json.dumps({field: {"mode": "exclude", "values": [value]}})}) + assert excluded.json()[total_key] == all_total - 1 + + +def test_multiple_columns_and_invalid_payload(client): + path = "/v1/config-sync/snapshots" + response = client.get(path, params={"column_filters": json.dumps({"source": {"mode": "include", "values": ["managed"]}, "vendor": {"mode": "include", "values": ["Cisco"]}})}) + assert response.status_code == 200 + assert response.json()["total"] == 0 + for payload in ("[]", "bad-json", '{"name":{"mode":"bad","values":[]}}', '{"name":{"mode":"include","values":[1]}}'): + assert client.get(path, params={"column_filters": payload}).status_code == 400 + assert client.get(path, params={"filter_field": "nonexistent"}).status_code == 400 + + +@pytest.mark.parametrize("path,field", [ + ("/v1/config-sync/snapshots", "size"), ("/v1/topology/fabric/nodes", "link"), + ("/v1/topology/fabric/edges", "aSide"), ("/v1/topology/fabric/edges", "missCount"), + ("/v1/topology/lldp-collect/jobs", "progress"), ("/v1/topology/lldp-collect/jobs/j1", "edges"), +]) +def test_computed_column_values_roundtrip(client, path, field): + response = client.get(path, params={"filter_field": field}) + assert response.status_code == 200, response.text + options = response.json()["options"] + assert options + for value in options: + filtered = client.get(path, params={"column_filters": json.dumps({field: {"mode": "include", "values": [value]}})}) + assert filtered.status_code == 200, filtered.text + assert filtered.json().get("total", filtered.json().get("items_total")) > 0 diff --git a/web/src/components/ui/ColumnFilterMenu.tsx b/web/src/components/ui/ColumnFilterMenu.tsx new file mode 100644 index 0000000..106c07d --- /dev/null +++ b/web/src/components/ui/ColumnFilterMenu.tsx @@ -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; + 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(value); + const [optionSearch, setOptionSearch] = useState(""); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const menuRef = useRef(null); + const [renderLimit, setRenderLimit] = useState(100); + const [portalContainer, setPortalContainer] = useState(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 ( + + + {open && portalContainer ? createPortal( +
{ + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + setOpen(false); + triggerRef.current?.focus(); + } + }}> + { + setOptionSearch(event.target.value); + setRenderLimit(100); + }} + /> + +
+ {optionsQuery.isLoading ? {loadingLabel} : null} + {!optionsQuery.isLoading && optionsQuery.isError ? ( +
+ {errorLabel} + +
+ ) : null} + {!optionsQuery.isLoading && !optionsQuery.isError && !visibleOptions.length ? ( + {noOptionsLabel} + ) : null} + {visibleOptions.slice(0, renderLimit).map((option) => ( + + ))} + {visibleOptions.length > renderLimit ? ( + + ) : null} +
+
+ + + +
+
, + portalContainer, + ) : null} +
+ ); +} diff --git a/web/src/features/network/LldpLinksPage.tsx b/web/src/features/network/LldpLinksPage.tsx index 5435f9a..58fce5f 100644 --- a/web/src/features/network/LldpLinksPage.tsx +++ b/web/src/features/network/LldpLinksPage.tsx @@ -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(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>({}); 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" ? (

{t("lldpLinks.selectedCount", { count: String(selectedCount) })}

-
- { - setTargetKeyword(e.target.value); - setTargetPage(1); - }} - /> -
- + +
- - - + + + + @@ -823,42 +773,12 @@ export function LldpLinksPage() {

{t("lldpLinks.edgesTitle")}

-
- { - const v = e.target.value; - setEdgeStatus(v === "active" || v === "missing" ? v : "all"); - setEdgePage(1); - }} - > - - - - - { - setEdgeSource(e.target.value); - setEdgePage(1); - }} - > - - - - - { - setEdgeKeyword(e.target.value); - setEdgePage(1); - }} - /> +
+
- {t("lldpLinks.col.source")}{t("lldpLinks.col.name")}IP{t("lldpLinks.col.vendor")}{targetColumns.column("source", t("lldpLinks.col.source"))}{targetColumns.column("name", t("lldpLinks.col.name"))}{targetColumns.column("ip", "IP")}{targetColumns.column("vendor", t("lldpLinks.col.vendor"))}
- - - - - - - - + + + + + + + + @@ -943,35 +863,12 @@ export function LldpLinksPage() { />

{t("lldpLinks.jobsTitle")}

-
- { - setJobStatus(e.target.value); - setJobPage(1); - }} - > - - - - - - - - - - { - setJobKeyword(e.target.value); - setJobPage(1); - }} - /> +
+
- - - - - - - - + + + + + + + + + @@ -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() { })()}

-
- { - setItemStatus(e.target.value); - setItemPage(1); - }} - > - - - - - - { - setItemKeyword(e.target.value); - setItemPage(1); - }} - /> -
+ {jobDetailQuery.isLoading ?

{t("common.refreshing")}

: null} {jobDetailQuery.data?.error ? ( @@ -1160,12 +1034,12 @@ export function LldpLinksPage() {
{t("lldpLinks.col.status")}{t("lldpLinks.col.aSide")}{t("lldpLinks.col.aPort")}{t("lldpLinks.col.bSide")}{t("lldpLinks.col.bPort")}{t("lldpLinks.col.source")}{t("lldpLinks.missCount")}{t("lldpLinks.col.lastSeen")}{edgeColumns.column("status", t("lldpLinks.col.status"), (value) => lldpEdgeStatusLabel(t, value))}{edgeColumns.column("aSide", t("lldpLinks.col.aSide"))}{edgeColumns.column("aPort", t("lldpLinks.col.aPort"))}{edgeColumns.column("bSide", t("lldpLinks.col.bSide"))}{edgeColumns.column("bPort", t("lldpLinks.col.bPort"))}{edgeColumns.column("source", t("lldpLinks.col.source"), (value) => lldpEdgeSourceLabel(t, value))}{edgeColumns.column("missCount", t("lldpLinks.missCount"))}{edgeColumns.column("lastSeen", t("lldpLinks.col.lastSeen"), formatSystemTime)}
- ID{t("lldpLinks.col.trigger")}{t("lldpLinks.col.scope")}{t("lldpLinks.col.status")}{t("lldpLinks.col.progress")}{t("lldpLinks.col.edges")}{t("lldpLinks.col.missingDelta")}{t("lldpLinks.col.started")}{t("lldpLinks.col.ended")}{jobColumns.column("id", "ID", (value) => value.slice(0, 8))}{jobColumns.column("trigger", t("lldpLinks.col.trigger"), (value) => lldpTriggerLabel(t, value))}{jobColumns.column("scope", t("lldpLinks.col.scope"), (value) => lldpScopeLabel(t, value))}{jobColumns.column("status", t("lldpLinks.col.status"), (value) => lldpJobStatusLabel(t, value))}{jobColumns.column("progress", t("lldpLinks.col.progress"))}{jobColumns.column("edges", t("lldpLinks.col.edges"), (value) => { const [added, updated] = value.split("/"); return `+${added} / ~${updated}`; })}{jobColumns.column("missingDelta", t("lldpLinks.col.missingDelta"))}{jobColumns.column("started", t("lldpLinks.col.started"), formatSystemTime)}{jobColumns.column("ended", t("lldpLinks.col.ended"), formatSystemTime)}
- - - - - - + + + + + + @@ -1202,7 +1076,7 @@ export function LldpLinksPage() { @@ -1238,7 +1112,7 @@ export function LldpLinksPage() {
{t("lldpLinks.col.name")}IP{t("lldpLinks.col.status")}{t("lldpLinks.col.neighbors")}{t("lldpLinks.col.edges")}{t("lldpLinks.col.error")}{itemColumns.column("name", t("lldpLinks.col.name"))}{itemColumns.column("ip", "IP")}{itemColumns.column("status", t("lldpLinks.col.status"), (value) => t(`lldpLinks.itemResult.${value}`))}{itemColumns.column("neighbors", t("lldpLinks.col.neighbors"))}{itemColumns.column("edges", t("lldpLinks.col.edges"), (value) => { const [added, updated] = value.split("/"); return `+${added} / ~${updated}`; })}{itemColumns.column("error", t("lldpLinks.col.error"))}
{it.error || "—"} -
+
- - - + + + - {(itemDetail.unmatched || []).map((u, idx) => ( + {unmatchedColumns.items.map((u, idx) => ( ))} + {!unmatchedColumns.items.length ? : null}
{t("topology.discoverColRemote")}{t("topology.discoverColLocalPort")}{t("topology.discoverColRemotePort")}{unmatchedColumns.column("remote", t("topology.discoverColRemote"))}{unmatchedColumns.column("localPort", t("topology.discoverColLocalPort"))}{unmatchedColumns.column("remotePort", t("topology.discoverColRemotePort"))}
{(u.remote_name || u.remote_ip || "?").trim()} @@ -1326,6 +1199,7 @@ export function LldpLinksPage() { {u.remote_port || "?"}
{t("common.empty")}
diff --git a/web/src/features/network/NetworkAlarmsPage.tsx b/web/src/features/network/NetworkAlarmsPage.tsx index c59da7e..37dcb8e 100644 --- a/web/src/features/network/NetworkAlarmsPage.tsx +++ b/web/src/features/network/NetworkAlarmsPage.tsx @@ -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() {

{t("ume.alarms.title")}

+ {curHostName || curKeyword ? {curHostName || curKeyword} : null} +
-
- { - setCurKeyword(e.target.value); - setCurPage(1); - }} - /> - { - setCurHostName(e.target.value); - setCurPage(1); - }} - /> - { - setCurSeverity(e.target.value); - setCurPage(1); - }} - > - - - - - - - - { - setCurCleared(e.target.value); - setCurPage(1); - }} - > - - - - - -
- {currentQuery.isLoading ?

{t("common.refreshing")}

: null} {currentQuery.isError ?

{t("common.opFailed")}

: null} - {!items.length && !currentQuery.isLoading ? ( -
-

{t("common.empty")}

-
- ) : (
- - - - - - + + + + + + + @@ -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() { + ))} + {!items.length && !currentQuery.isFetching && !currentQuery.isError ? ( + + ) : null}
{t("ume.alarms.col.time")}{t("ume.alarms.col.severity")}{t("ume.alarms.col.neId")}{t("ume.alarms.col.hostName")}{t("ume.alarms.col.neType")}{t("ume.alarms.col.cause")}{columns.column("time", t("ume.alarms.col.time"), formatSystemTime)}{columns.column("severity", t("ume.alarms.col.severity"))}{columns.column("neId", t("ume.alarms.col.neId"))}{columns.column("hostName", t("ume.alarms.col.hostName"))}{columns.column("neType", t("ume.alarms.col.neType"))}{columns.column("cause", t("ume.alarms.col.cause"))}{columns.column("cleared", t("ume.alarms.col.cleared"), (value) => t(value === "true" ? "ume.alarms.clearedYes" : "ume.alarms.clearedNo"))}
{x.ne_type ?? ""} {x.native_probable_cause}{t(x.is_cleared === "true" ? "ume.alarms.clearedYes" : "ume.alarms.clearedNo")}
{t("common.empty")}
- )} 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() {

{t("networkConfigs.title")}

+ {keyword ? {keyword} : null} +
-
- { - setKeyword(e.target.value); - setPage(1); - }} - /> - { - setSource(e.target.value); - setPage(1); - }} - > - - - - - { - setVendor(e.target.value); - setPage(1); - }} - /> - -
- {listQuery.isLoading ?

{t("common.refreshing")}

: null} {listQuery.isError ?

{t("common.opFailed")}

: null} - {!items.length && !listQuery.isLoading ? ( -
-

{t("networkConfigs.empty")}

-
- ) : (
- - - - - - + + + + + + @@ -292,10 +228,12 @@ export function NetworkConfigsPage() { ); })} + {!items.length && !listQuery.isFetching && !listQuery.isError ? ( + + ) : null}
{t("networkConfigs.col.name")}IP{t("networkConfigs.col.vendor")}{t("networkConfigs.col.source")}{t("networkConfigs.col.size")}{t("networkConfigs.col.collected")}{columns.column("name", t("networkConfigs.col.name"))}{columns.column("ip", "IP")}{columns.column("vendor", t("networkConfigs.col.vendor"))}{columns.column("source", t("networkConfigs.col.source"))}{columns.column("size", t("networkConfigs.col.size"), (value) => fmtBytes(Number(value)))}{columns.column("collected", t("networkConfigs.col.collected"), formatSystemTime)} {t("portTraffic.col.actions")}
{t("networkConfigs.empty")}
- )} ; + +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(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() {

{t("networkDevices.title")}

+
-
- { - setKeyword(e.target.value); - setPage(1); - }} - /> - { - setSource(e.target.value as "all" | "managed" | "ume"); - setPage(1); - }} - > - - - - - -
- - {listQuery.isLoading ?

{t("common.refreshing")}

: null} + {listQuery.isFetching ?

{t("common.refreshing")}

: null} {listQuery.isError ?

{t("common.opFailed")}

: null} - {!items.length && !listQuery.isLoading ? ( -
-

{t("networkDevices.empty")}

-
- ) : ( -
- - - - - - - - - - - - - - {items.map((row) => ( - - - - - - - - - +
+
{t("networkDevices.col.source")}{t("networkDevices.col.name")}IP{t("networkDevices.col.vendor")}{t("networkDevices.col.deviceType")}{t("networkDevices.col.connect")}{t("networkDevices.col.actions")}
- {row.source} - {row.name || row.id}{row.ip_address || "—"}{row.vendor || "—"}{row.device_type || row.ne_type || "—"} - - {row.connect_status || "—"} - - -
- - -
-
+ + + {([ + ["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]) => ( + ))} - -
+ + {label} + fetchCliTargetFilterOptions({ field: key, source: "inventory" }).then((result) => result.options)} + onApply={(value) => { + setColumnFilters((current) => ({ ...current, [key]: value })); + setPage(1); + }} + /> + +
+ {t("networkDevices.col.actions")} + + + + {items.map((row) => ( + + + {row.source} + + {row.name || row.id} + {row.ip_address || "—"} + {row.vendor || "—"} + {row.device_type || row.ne_type || "—"} + + + {row.connect_status || "—"} + + + +
+ + +
+ + + ))} + {!items.length && !listQuery.isFetching && !listQuery.isError ? ( + + +
+

{t(hasFilters ? "networkDevices.emptyFiltered" : "networkDevices.empty")}

+
+ + + ) : null} + +
- )} { + 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() {
-

{t("topoClassify.inventory")}

-
- { - setKeyword(e.target.value); - setPage(1); - }} - /> - - - - +
+

{t("topoClassify.inventory")}

+
-
{t("topoClassify.regexTitle")}
- +
+
- - - - - + + + + + @@ -573,6 +491,7 @@ export function TopologyClassifyPage() { ) : null}
@@ -467,11 +385,11 @@ export function TopologyClassifyPage() { aria-label={t("topoClassify.selectPage")} /> {t("topoClassify.colNe")}IP{t("topoClassify.colLink")}{t("topoClassify.colLevel")}{t("topoClassify.colRegion")}{columns.column("name", t("topoClassify.colNe"))}{columns.column("ip", "IP")}{columns.column("link", t("topoClassify.colLink"), linkLabel)}{columns.column("level", t("topoClassify.colLevel"), (value) => formatLevel(Number(value)))}{columns.column("region", t("topoClassify.colRegion"), (value) => regionName[value] || value)} {t("topoClassify.colActions")}
+
(scope: string, rows: T[], values: (row: T) => Record) { + const [filters, setFilters] = useState({}); + 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) => ( + + {label} + values(row)[field] ?? ""))]} + loadOptions={() => Promise.resolve([...new Set(rows.map((row) => values(row)[field] ?? ""))].sort())} + onApply={(value) => setFilters((current) => ({ ...current, [field]: value }))} /> + + ), + }; +} diff --git a/web/src/hooks/useTableColumnFilters.tsx b/web/src/hooks/useTableColumnFilters.tsx new file mode 100644 index 0000000..7779c15 --- /dev/null +++ b/web/src/hooks/useTableColumnFilters.tsx @@ -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 = {}) { + const [filters, setFilters] = useState({}); + 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) => ( + + {label} + fetchTableFilterOptions(endpoint, field, scope)} + formatValue={formatValue} onApply={(value) => setFilter(field, value)} /> + + ), + }; +} diff --git a/web/src/i18n/locales/en/networkDevices.ts b/web/src/i18n/locales/en/networkDevices.ts index bc31e5d..8261491 100644 --- a/web/src/i18n/locales/en/networkDevices.ts +++ b/web/src/i18n/locales/en/networkDevices.ts @@ -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", diff --git a/web/src/i18n/locales/en/ume.ts b/web/src/i18n/locales/en/ume.ts index 31354c5..5aeef16 100644 --- a/web/src/i18n/locales/en/ume.ts +++ b/web/src/i18n/locales/en/ume.ts @@ -271,6 +271,7 @@ const ume = { hostName: "Host", neType: "NE type", cause: "Cause", + cleared: "Cleared status", }, }, }; diff --git a/web/src/i18n/locales/zh/networkDevices.ts b/web/src/i18n/locales/zh/networkDevices.ts index 5f55ae9..b1f59b9 100644 --- a/web/src/i18n/locales/zh/networkDevices.ts +++ b/web/src/i18n/locales/zh/networkDevices.ts @@ -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: "来源", diff --git a/web/src/i18n/locales/zh/ume.ts b/web/src/i18n/locales/zh/ume.ts index 2166074..ba880a4 100644 --- a/web/src/i18n/locales/zh/ume.ts +++ b/web/src/i18n/locales/zh/ume.ts @@ -271,6 +271,7 @@ const ume = { hostName: "主机名", neType: "网元类型", cause: "原因", + cleared: "清除状态", }, }, }; diff --git a/web/src/services/api/cli.ts b/web/src/services/api/cli.ts index 5bdcab5..fb8e0ea 100644 --- a/web/src/services/api/cli.ts +++ b/web/src/services/api/cli.ts @@ -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("/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(`/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 = { + 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(`/v1/cli/targets?${p.toString()}`); }; @@ -33,6 +63,7 @@ export const fetchUmeCliOverride = (umeNeId: string) => apiGet(`/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); diff --git a/web/src/services/api/configSync.ts b/web/src/services/api/configSync.ts index a44ffa5..4b1c515 100644 --- a/web/src/services/api/configSync.ts +++ b/web/src/services/api/configSync.ts @@ -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()); diff --git a/web/src/services/api/tableFilters.ts b/web/src/services/api/tableFilters.ts new file mode 100644 index 0000000..389d0f9 --- /dev/null +++ b/web/src/services/api/tableFilters.ts @@ -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 = {}) { + const params = new URLSearchParams(scope); + params.set("filter_field", field); + return apiGet<{ field: string; options: string[] }>(`${endpoint}?${params}`).then((data) => data.options); +} diff --git a/web/src/services/api/topology.ts b/web/src/services/api/topology.ts index bd68d11..a444804 100644 --- a/web/src/services/api/topology.ts +++ b/web/src/services/api/topology.ts @@ -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("/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(`/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()); diff --git a/web/src/styles/layout/network-shell.css b/web/src/styles/layout/network-shell.css index 01b5fab..7f7a8b1 100644 --- a/web/src/styles/layout/network-shell.css +++ b/web/src/styles/layout/network-shell.css @@ -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; } diff --git a/web/src/types/cli.ts b/web/src/types/cli.ts index d8eda4f..1838f7b 100644 --- a/web/src/types/cli.ts +++ b/web/src/types/cli.ts @@ -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; diff --git a/web/src/types/tableFilters.ts b/web/src/types/tableFilters.ts new file mode 100644 index 0000000..81ecba8 --- /dev/null +++ b/web/src/types/tableFilters.ts @@ -0,0 +1,2 @@ +export type ColumnValueFilter = { mode: "include" | "exclude"; values: string[] } | null; +export type TableColumnFilters = Record;