feat: unify network and topology table column filters

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

View file

@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .cli_schemas import ( from .cli_schemas import (
@ -15,12 +15,14 @@ from .cli_service import (
delete_cli_profile, delete_cli_profile,
get_cli_profile, get_cli_profile,
get_ume_cli_override, get_ume_cli_override,
list_cli_target_filter_options,
list_cli_profiles, list_cli_profiles,
list_cli_targets, list_cli_targets,
set_default_cli_profile, set_default_cli_profile,
update_cli_profile, update_cli_profile,
upsert_ume_cli_override, upsert_ume_cli_override,
) )
from .table_filters import TableFilterParams, table_filter_params
from .db import get_db from .db import get_db
from .ne_connect import schedule_ume_connect_tests 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( def api_list_cli_targets(
source: str = Query(default="all"), source: str = Query(default="all"),
keyword: str | None = Query(default=None), 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: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500), page_size: int = Query(default=50, ge=1, le=500),
table_filters: TableFilterParams = Depends(table_filter_params),
db: Session = Depends(get_db), 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}") @router.get("/ume-overrides/{ume_ne_id}")

View file

@ -4,6 +4,7 @@ from datetime import datetime
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .cli_schemas import ( from .cli_schemas import (
@ -279,19 +280,66 @@ def list_cli_targets(
*, *,
source: str = "all", source: str = "all",
keyword: str | None = None, 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: int = 1,
page_size: int = 50, page_size: int = 50,
) -> dict[str, Any]: ) -> dict[str, Any]:
from .device_types import WEBCRT_NE_SOURCE from .device_types import WEBCRT_NE_SOURCE
src = str(source or "all").strip().lower() 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") raise HTTPException(status_code=400, detail="invalid_source")
ready = cli_profile_ready(db) ready = cli_profile_ready(db)
page = max(1, int(page or 1)) page = max(1, int(page or 1))
page_size = max(1, min(500, int(page_size or 50))) page_size = max(1, min(500, int(page_size or 50)))
offset = (page - 1) * page_size offset = (page - 1) * page_size
kw = str(keyword or "").strip() 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_profile = get_default_profile(db)
default_hop = bool(getattr(default_profile, "hop_enabled", False)) if default_profile else False default_hop = bool(getattr(default_profile, "hop_enabled", False)) if default_profile else False
@ -351,7 +399,8 @@ def list_cli_targets(
return stmt return stmt
like = f"%{kw}%" like = f"%{kw}%"
return stmt.filter( return stmt.filter(
ManagedNE.name.ilike(like) ManagedNE.id.ilike(like)
| ManagedNE.name.ilike(like)
| ManagedNE.ip_address.ilike(like) | ManagedNE.ip_address.ilike(like)
| ManagedNE.username.ilike(like) | ManagedNE.username.ilike(like)
| ManagedNE.tags.ilike(like) | ManagedNE.tags.ilike(like)
@ -362,16 +411,42 @@ def list_cli_targets(
def _inventory_managed_query(): def _inventory_managed_query():
# Asset inventory for WebCRT: exclude Quick-Connect (source=webcrt) rows. # Asset inventory for WebCRT: exclude Quick-Connect (source=webcrt) rows.
stmt = db.query(ManagedNE).filter(ManagedNE.source != WEBCRT_NE_SOURCE) 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(): def _webcrt_query():
stmt = db.query(ManagedNE).filter(ManagedNE.source == WEBCRT_NE_SOURCE) 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(): def _ume_query():
stmt = db.query(UmeInventoryNE, UmeCliOverride).outerjoin( stmt = db.query(UmeInventoryNE, UmeCliOverride).outerjoin(
UmeCliOverride, UmeInventoryNE.ne_id == UmeCliOverride.ume_ne_id UmeCliOverride, UmeInventoryNE.ne_id == UmeCliOverride.ume_ne_id
) )
if not _source_allowed("ume"):
stmt = stmt.filter(False)
if kw: if kw:
like = f"%{kw}%" like = f"%{kw}%"
stmt = stmt.filter( stmt = stmt.filter(
@ -381,8 +456,46 @@ def list_cli_targets(
| UmeInventoryNE.ip_address.ilike(like) | UmeInventoryNE.ip_address.ilike(like)
| UmeInventoryNE.host_name.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()) 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": if src == "managed":
mq = _inventory_managed_query() mq = _inventory_managed_query()
total = int(mq.count()) total = int(mq.count())
@ -443,6 +556,118 @@ def list_cli_targets(
return {"items": items, "total": total, "page": page, "page_size": page_size} 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]: def cli_meta(db: Session) -> dict[str, Any]:
return { return {
"credentials_configured": credentials_configured(), "credentials_configured": credentials_configured(),

View file

@ -27,6 +27,7 @@ from .config_sync_service import (
stop_cycle, stop_cycle,
update_policy, update_policy,
) )
from .table_filters import TableFilterParams, table_filter_params
from .db import get_db from .db import get_db
router = APIRouter(prefix="/v1/config-sync", tags=["config-sync"]) router = APIRouter(prefix="/v1/config-sync", tags=["config-sync"])
@ -164,10 +165,11 @@ def api_list_snapshots(
keyword: str = Query(default=""), keyword: str = Query(default=""),
source: str = Query(default=""), source: str = Query(default=""),
vendor: str = Query(default=""), vendor: str = Query(default=""),
table_filters: TableFilterParams = Depends(table_filter_params),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
return list_snapshots( 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
) )

View file

@ -7,7 +7,7 @@ import zipfile
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import or_ from sqlalchemy import func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config_sync_codec import decompress_text from .config_sync_codec import decompress_text
@ -16,6 +16,7 @@ from .config_sync_schemas import (
NeConfigSnapshotDetailOut, NeConfigSnapshotDetailOut,
NeConfigSnapshotMetaOut, NeConfigSnapshotMetaOut,
) )
from .table_filters import TableFilterParams, filter_query
from .models import NeConfigHistory, NeConfigSnapshot from .models import NeConfigHistory, NeConfigSnapshot
def _snap_meta(row: NeConfigSnapshot) -> NeConfigSnapshotMetaOut: def _snap_meta(row: NeConfigSnapshot) -> NeConfigSnapshotMetaOut:
@ -48,6 +49,7 @@ def list_snapshots(
keyword: str = "", keyword: str = "",
source: str = "", source: str = "",
vendor: str = "", vendor: str = "",
table_filters: TableFilterParams | None = None,
) -> dict[str, Any]: ) -> dict[str, Any]:
q = db.query(NeConfigSnapshot) q = db.query(NeConfigSnapshot)
src = str(source or "").strip().lower() src = str(source or "").strip().lower()
@ -66,6 +68,14 @@ def list_snapshots(
NeConfigSnapshot.target_id.ilike(like), 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()) total = int(q.count())
rows = q.order_by(NeConfigSnapshot.collected_at.desc()).offset((page - 1) * page_size).limit(page_size).all() 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]} return {"total": total, "page": page, "page_size": page_size, "items": [_snap_meta(r) for r in rows]}

View file

@ -7,6 +7,7 @@ from typing import Any
from fastapi import APIRouter, Depends, Query from fastapi import APIRouter, Depends, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .table_filters import TableFilterParams, table_filter_params
from .db import get_db from .db import get_db
from .lldp_collect_schemas import LldpCollectPolicyUpdate, LldpCollectStartBody from .lldp_collect_schemas import LldpCollectPolicyUpdate, LldpCollectStartBody
from .lldp_collect_service import ( from .lldp_collect_service import (
@ -69,9 +70,10 @@ def api_list_jobs(
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
status: str = Query(default=""), status: str = Query(default=""),
keyword: str = Query(default=""), keyword: str = Query(default=""),
table_filters: TableFilterParams = Depends(table_filter_params),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> 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}") @router.get("/jobs/{job_id}")
@ -81,6 +83,7 @@ def api_get_job(
page_size: int = Query(default=20, ge=1, le=100), page_size: int = Query(default=20, ge=1, le=100),
status: str = Query(default=""), status: str = Query(default=""),
keyword: str = Query(default=""), keyword: str = Query(default=""),
table_filters: TableFilterParams = Depends(table_filter_params),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> dict[str, Any]:
return get_job_detail( return get_job_detail(
@ -90,4 +93,5 @@ def api_get_job(
page_size=page_size, page_size=page_size,
item_status=status, item_status=status,
item_keyword=keyword, item_keyword=keyword,
table_filters=table_filters,
) )

View file

@ -5,9 +5,11 @@ from __future__ import annotations
from datetime import datetime, timedelta from datetime import datetime, timedelta
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import or_ from sqlalchemy import String, cast, or_
from sqlalchemy.orm import Session 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 ( from .lldp_collect_schemas import (
LldpCollectDashboardOut, LldpCollectDashboardOut,
LldpCollectJobSummary, LldpCollectJobSummary,
@ -488,6 +490,7 @@ def list_jobs(
page_size: int = 20, page_size: int = 20,
status: str = "", status: str = "",
keyword: str = "", keyword: str = "",
table_filters: TableFilterParams | None = None,
) -> dict: ) -> dict:
page = max(1, int(page or 1)) page = max(1, int(page or 1))
page_size = max(1, min(100, int(page_size or 20))) page_size = max(1, min(100, int(page_size or 20)))
@ -506,6 +509,15 @@ def list_jobs(
TopoDiscoverJob.trigger_mode.ilike(like), 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()) q = q.order_by(TopoDiscoverJob.created_at.desc())
total = int(q.count()) total = int(q.count())
rows = q.offset((page - 1) * page_size).limit(page_size).all() rows = q.offset((page - 1) * page_size).limit(page_size).all()
@ -532,7 +544,14 @@ def get_job_detail(
page_size: int | None = None, page_size: int | None = None,
item_status: str = "", item_status: str = "",
item_keyword: str = "", item_keyword: str = "",
table_filters: TableFilterParams | None = None,
) -> dict: ) -> 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( return get_discover_job(
db, db,
job_id, job_id,
@ -540,4 +559,5 @@ def get_job_detail(
page_size=page_size, page_size=page_size,
item_status=item_status, item_status=item_status,
item_keyword=item_keyword, item_keyword=item_keyword,
table_filters=table_filters,
).model_dump() ).model_dump()

55
netx_api/table_filters.py Normal file
View file

@ -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

View file

@ -4,9 +4,10 @@ from __future__ import annotations
from typing import Any from typing import Any
from fastapi import HTTPException from fastapi import HTTPException
from sqlalchemy import or_ from sqlalchemy import String, case, cast, func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .table_filters import TableFilterParams, filter_query
from .cli_resolve import get_default_profile, infer_device_type_vendor from .cli_resolve import get_default_profile, infer_device_type_vendor
from .device_types import is_placeholder_ne_source from .device_types import is_placeholder_ne_source
from .models import ManagedNE, TopoDiscoverJob, TopoDiscoverJobItem, UmeInventoryNE 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( def _job_out(
db: Session, db: Session,
job: TopoDiscoverJob, job: TopoDiscoverJob,
@ -51,6 +63,7 @@ def _job_out(
page_size: int | None = None, page_size: int | None = None,
item_status: str = "", item_status: str = "",
item_keyword: str = "", item_keyword: str = "",
table_filters: TableFilterParams | None = None,
) -> FabricDiscoverJobOut: ) -> FabricDiscoverJobOut:
items_out: list[FabricDiscoverJobItemOut] = [] items_out: list[FabricDiscoverJobItemOut] = []
items_total = 0 items_total = 0
@ -93,6 +106,7 @@ def _job_out(
TopoDiscoverJobItem.error.ilike(like), TopoDiscoverJobItem.error.ilike(like),
) )
) )
q, _ = filter_query(q, discover_item_expressions(), table_filters)
items_total = int(q.count()) items_total = int(q.count())
if page is not None and page_size is not None: if page is not None and page_size is not None:
items_page = max(1, int(page or 1)) items_page = max(1, int(page or 1))
@ -157,6 +171,7 @@ def get_discover_job(
page_size: int | None = None, page_size: int | None = None,
item_status: str = "", item_status: str = "",
item_keyword: str = "", item_keyword: str = "",
table_filters: TableFilterParams | None = None,
) -> FabricDiscoverJobOut: ) -> FabricDiscoverJobOut:
job = db.get(TopoDiscoverJob, str(job_id or "").strip()) job = db.get(TopoDiscoverJob, str(job_id or "").strip())
if job is None: if job is None:
@ -168,6 +183,7 @@ def get_discover_job(
page_size=page_size, page_size=page_size,
item_status=item_status, item_status=item_status,
item_keyword=item_keyword, item_keyword=item_keyword,
table_filters=table_filters,
) )

View file

@ -8,10 +8,11 @@ from typing import Any
from uuid import uuid4 from uuid import uuid4
from fastapi import HTTPException 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.exc import IntegrityError
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .table_filters import TableFilterParams, filter_query
from .cli_resolve import get_default_profile, infer_device_type_vendor from .cli_resolve import get_default_profile, infer_device_type_vendor
from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE
from .models import ( from .models import (
@ -275,6 +276,7 @@ def list_fabric_nodes(
region_folder_id: str = "", region_folder_id: str = "",
unmatched: str = "", unmatched: str = "",
link_status: str = "", link_status: str = "",
table_filters: TableFilterParams | None = None,
page: int = 1, page: int = 1,
page_size: int = PAGE_DEFAULT, page_size: int = PAGE_DEFAULT,
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -375,6 +377,16 @@ def list_fabric_nodes(
and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""), and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""),
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_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()) total = int(q.count())
rows = ( rows = (
q.order_by(TopoFabricNode.name.asc()) q.order_by(TopoFabricNode.name.asc())
@ -399,6 +411,7 @@ def list_fabric_edges(
status: str = "", status: str = "",
source: str = "", source: str = "",
keyword: str = "", keyword: str = "",
table_filters: TableFilterParams | None = None,
page: int = 1, page: int = 1,
page_size: int = PAGE_DEFAULT, page_size: int = PAGE_DEFAULT,
) -> dict[str, Any]: ) -> dict[str, Any]:
@ -438,6 +451,23 @@ def list_fabric_edges(
TopoFabricEdge.b_node_id.in_(matched_ids), 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()) total = int(q.count())
rows = ( rows = (
q.order_by(TopoFabricEdge.updated_at.desc()) q.order_by(TopoFabricEdge.updated_at.desc())

View file

@ -7,6 +7,7 @@ from typing import Any
from fastapi import APIRouter, Body, Depends, HTTPException, Query from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .table_filters import TableFilterParams, table_filter_params
from .db import get_db from .db import get_db
from .topology_classify import ( from .topology_classify import (
apply_classify, apply_classify,
@ -107,6 +108,7 @@ def api_fabric_nodes(
), ),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=100, ge=1, le=2000), page_size: int = Query(default=100, ge=1, le=2000),
table_filters: TableFilterParams = Depends(table_filter_params),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> dict[str, Any]:
return list_fabric_nodes( return list_fabric_nodes(
@ -118,6 +120,7 @@ def api_fabric_nodes(
region_folder_id=region_folder_id, region_folder_id=region_folder_id,
unmatched=unmatched, unmatched=unmatched,
link_status=link_status, link_status=link_status,
table_filters=table_filters,
page=page, page=page,
page_size=page_size, page_size=page_size,
) )
@ -132,6 +135,7 @@ def api_fabric_edges(
keyword: str = "", keyword: str = "",
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=100, ge=1, le=2000), page_size: int = Query(default=100, ge=1, le=2000),
table_filters: TableFilterParams = Depends(table_filter_params),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> dict[str, Any]:
return list_fabric_edges( return list_fabric_edges(
@ -141,6 +145,7 @@ def api_fabric_edges(
status=status, status=status,
source=source, source=source,
keyword=keyword, keyword=keyword,
table_filters=table_filters,
page=page, page=page,
page_size=page_size, page_size=page_size,
) )

View file

@ -10,6 +10,8 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from .config import settings from .config import settings
from .table_filters import TableFilterParams, filter_query
from .table_filters import table_filter_params
from .db import get_db from .db import get_db
from .key_alert_config import ( from .key_alert_config import (
get_key_alert_monitor_config, 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 <="), time_to: str | None = Query(default=None, description="Filter by last_seen_at <="),
page: int = Query(default=1, ge=1), page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500), page_size: int = Query(default=50, ge=1, le=500),
table_filters: TableFilterParams = Depends(table_filter_params),
db: Session = Depends(get_db), db: Session = Depends(get_db),
) -> dict[str, Any]: ) -> dict[str, Any]:
stmt = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin( 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)) stmt = stmt.filter(UmeAlarmCurrent.last_seen_at >= dt_from.replace(tzinfo=None))
if dt_to: if dt_to:
stmt = stmt.filter(UmeAlarmCurrent.last_seen_at <= dt_to.replace(tzinfo=None)) 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()) total = int(stmt.count())
rows = ( rows = (
stmt.order_by( stmt.order_by(

View file

@ -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 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) @pytest.fixture(autouse=True)
def _fresh_baseline_row_cache(): def _fresh_baseline_row_cache():
"""Test databases reuse batch ids; the process-wide baseline cache must not leak between tests.""" """Test databases reuse batch ids; the process-wide baseline cache must not leak between tests."""

View file

@ -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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -3,6 +3,18 @@ const networkDevices = {
keywordPh: "Name / IP / ID", keywordPh: "Name / IP / ID",
allSource: "All sources", allSource: "All sources",
empty: "No network elements.", 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", exportName: "network-devices",
col: { col: {
source: "Source", source: "Source",

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -341,6 +341,126 @@
margin: 0; 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 { .network-main .pt-list-table thead th {
background: var(--nx-surface-3); background: var(--nx-surface-3);
color: var(--nx-text-muted); color: var(--nx-text-muted);
@ -689,3 +809,23 @@
font-size: 12px; font-size: 12px;
color: var(--nx-text-soft); color: var(--nx-text-soft);
} }
/* Native checkboxes must not inherit text-input dimensions/padding. */
.ne-column-filter__menu input[type="checkbox"] {
appearance: auto;
flex: 0 0 16px;
width: 16px;
height: 16px;
min-height: 0;
margin: 0;
padding: 0;
cursor: pointer;
}
.ne-column-filter__menu input[type="checkbox"]:disabled {
cursor: wait;
}
.ne-column-filter__select-all:hover,
.ne-column-filter__option:hover {
background: var(--nx-surface-3);
}
.ne-column-filter__menu .button { flex-shrink: 0; }

View file

@ -48,6 +48,24 @@ export type CliTargetItem = {
ne_source?: string; 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 = { export type CliTargetListResponse = {
total: number; total: number;
page: number; page: number;

View file

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