mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 21:10:46 +08:00
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:
parent
ed820126df
commit
3c9e0efd2b
32 changed files with 1429 additions and 645 deletions
|
|
@ -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}")
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
55
netx_api/table_filters.py
Normal 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
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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())
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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(
|
||||||
|
|
|
||||||
|
|
@ -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."""
|
||||||
|
|
|
||||||
112
tests/test_table_column_filters.py
Normal file
112
tests/test_table_column_filters.py
Normal 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
|
||||||
251
web/src/components/ui/ColumnFilterMenu.tsx
Normal file
251
web/src/components/ui/ColumnFilterMenu.tsx
Normal 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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}
|
||||||
|
|
|
||||||
|
|
@ -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,101 +125,88 @@ 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-table-wrap">
|
||||||
<div className="pt-list-empty">
|
<table className="data-table pt-list-table">
|
||||||
<p>{t("networkDevices.empty")}</p>
|
<thead>
|
||||||
</div>
|
<tr>
|
||||||
) : (
|
{([
|
||||||
<div className="pt-list-table-wrap">
|
["source", t("networkDevices.col.source")],
|
||||||
<table className="data-table pt-list-table">
|
["name", t("networkDevices.col.name")],
|
||||||
<thead>
|
["ip", "IP"],
|
||||||
<tr>
|
["vendor", t("networkDevices.col.vendor")],
|
||||||
<th>{t("networkDevices.col.source")}</th>
|
["deviceType", t("networkDevices.col.deviceType")],
|
||||||
<th>{t("networkDevices.col.name")}</th>
|
["connectStatus", t("networkDevices.col.connect")],
|
||||||
<th>IP</th>
|
] as const).map(([key, label]) => (
|
||||||
<th>{t("networkDevices.col.vendor")}</th>
|
<th key={key}>
|
||||||
<th>{t("networkDevices.col.deviceType")}</th>
|
<span className="ne-column-heading">
|
||||||
<th>{t("networkDevices.col.connect")}</th>
|
{label}
|
||||||
<th>{t("networkDevices.col.actions")}</th>
|
<ColumnFilterMenu
|
||||||
</tr>
|
label={label}
|
||||||
</thead>
|
value={columnFilters[key]}
|
||||||
<tbody>
|
queryKey={["cliTargetFilterOptions", key, "inventory", ""]}
|
||||||
{items.map((row) => (
|
loadOptions={() => fetchCliTargetFilterOptions({ field: key, source: "inventory" }).then((result) => result.options)}
|
||||||
<tr key={`${row.source}:${row.id}`}>
|
onApply={(value) => {
|
||||||
<td>
|
setColumnFilters((current) => ({ ...current, [key]: value }));
|
||||||
<NmStatusChip color={sourceChipColor(row.source)}>{row.source}</NmStatusChip>
|
setPage(1);
|
||||||
</td>
|
}}
|
||||||
<td className="pt-list-task-name">{row.name || row.id}</td>
|
/>
|
||||||
<td className="pt-list-num">{row.ip_address || "—"}</td>
|
</span>
|
||||||
<td>{row.vendor || "—"}</td>
|
</th>
|
||||||
<td>{row.device_type || row.ne_type || "—"}</td>
|
|
||||||
<td>
|
|
||||||
<NmStatusChip color={connectChipColor(row.connect_status)}>
|
|
||||||
{row.connect_status || "—"}
|
|
||||||
</NmStatusChip>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<div className="btn-row pt-list-actions table-actions">
|
|
||||||
<Button size="sm" variant="ghost" onPress={() => openWebcrt(row)}>
|
|
||||||
WebCRT
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
variant="ghost"
|
|
||||||
onPress={() =>
|
|
||||||
openNewModuleWindow({
|
|
||||||
moduleId: "network",
|
|
||||||
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{t("network.nav.configs")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
))}
|
||||||
</tbody>
|
<th>{t("networkDevices.col.actions")}</th>
|
||||||
</table>
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{items.map((row) => (
|
||||||
|
<tr key={`${row.source}:${row.id}`}>
|
||||||
|
<td>
|
||||||
|
<NmStatusChip color={sourceChipColor(row.source)}>{row.source}</NmStatusChip>
|
||||||
|
</td>
|
||||||
|
<td className="pt-list-task-name">{row.name || row.id}</td>
|
||||||
|
<td className="pt-list-num">{row.ip_address || "—"}</td>
|
||||||
|
<td>{row.vendor || "—"}</td>
|
||||||
|
<td>{row.device_type || row.ne_type || "—"}</td>
|
||||||
|
<td>
|
||||||
|
<NmStatusChip color={connectChipColor(row.connect_status)}>
|
||||||
|
{row.connect_status || "—"}
|
||||||
|
</NmStatusChip>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="btn-row pt-list-actions table-actions">
|
||||||
|
<Button size="sm" variant="ghost" onPress={() => openWebcrt(row)}>
|
||||||
|
WebCRT
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onPress={() =>
|
||||||
|
openNewModuleWindow({
|
||||||
|
moduleId: "network",
|
||||||
|
path: `/network/configs?q=${encodeURIComponent(row.name || row.ip_address || row.id)}`,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{t("network.nav.configs")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
{!items.length && !listQuery.isFetching && !listQuery.isError ? (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={7}>
|
||||||
|
<div className="pt-list-empty">
|
||||||
|
<p>{t(hasFilters ? "networkDevices.emptyFiltered" : "networkDevices.empty")}</p>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : null}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<ListPager
|
<ListPager
|
||||||
page={page}
|
page={page}
|
||||||
|
|
|
||||||
|
|
@ -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">
|
||||||
<h3>{t("topoClassify.inventory")}</h3>
|
<div className="panel__toolbar">
|
||||||
<div className="topo-classify__draft">
|
<h3>{t("topoClassify.inventory")}</h3>
|
||||||
<Input
|
<Button size="sm" variant="tertiary" isDisabled={!columns.active} onPress={columns.reset}>{t("common.clearFilters")}</Button>
|
||||||
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}
|
||||||
|
|
|
||||||
25
web/src/hooks/useLocalTableColumnFilters.tsx
Normal file
25
web/src/hooks/useLocalTableColumnFilters.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
34
web/src/hooks/useTableColumnFilters.tsx
Normal file
34
web/src/hooks/useTableColumnFilters.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,7 @@ const ume = {
|
||||||
hostName: "Host",
|
hostName: "Host",
|
||||||
neType: "NE type",
|
neType: "NE type",
|
||||||
cause: "Cause",
|
cause: "Cause",
|
||||||
|
cleared: "Cleared status",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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: "来源",
|
||||||
|
|
|
||||||
|
|
@ -271,6 +271,7 @@ const ume = {
|
||||||
hostName: "主机名",
|
hostName: "主机名",
|
||||||
neType: "网元类型",
|
neType: "网元类型",
|
||||||
cause: "原因",
|
cause: "原因",
|
||||||
|
cleared: "清除状态",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
13
web/src/services/api/tableFilters.ts
Normal file
13
web/src/services/api/tableFilters.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
@ -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());
|
||||||
|
|
|
||||||
|
|
@ -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; }
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
2
web/src/types/tableFilters.ts
Normal file
2
web/src/types/tableFilters.ts
Normal file
|
|
@ -0,0 +1,2 @@
|
||||||
|
export type ColumnValueFilter = { mode: "include" | "exclude"; values: string[] } | null;
|
||||||
|
export type TableColumnFilters = Record<string, ColumnValueFilter>;
|
||||||
Loading…
Add table
Add a link
Reference in a new issue