feat: unify network and topology table column filters

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

View file

@ -1,6 +1,6 @@
from __future__ import annotations
from fastapi import APIRouter, Depends, Query
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.orm import Session
from .cli_schemas import (
@ -15,12 +15,14 @@ from .cli_service import (
delete_cli_profile,
get_cli_profile,
get_ume_cli_override,
list_cli_target_filter_options,
list_cli_profiles,
list_cli_targets,
set_default_cli_profile,
update_cli_profile,
upsert_ume_cli_override,
)
from .table_filters import TableFilterParams, table_filter_params
from .db import get_db
from .ne_connect import schedule_ume_connect_tests
@ -66,11 +68,57 @@ def api_delete_cli_profile(profile_id: str, db: Session = Depends(get_db)):
def api_list_cli_targets(
source: str = Query(default="all"),
keyword: str | None = Query(default=None),
source_filter: list[str] | None = Query(default=None),
source_filter_mode: str | None = Query(default=None),
name_filter: list[str] | None = Query(default=None),
name_filter_mode: str | None = Query(default=None),
ip_filter: list[str] | None = Query(default=None),
ip_filter_mode: str | None = Query(default=None),
vendor_filter: list[str] | None = Query(default=None),
vendor_filter_mode: str | None = Query(default=None),
device_type_filter: list[str] | None = Query(default=None),
device_type_filter_mode: str | None = Query(default=None),
connect_status_filter: list[str] | None = Query(default=None),
connect_status_filter_mode: str | None = Query(default=None),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500),
table_filters: TableFilterParams = Depends(table_filter_params),
db: Session = Depends(get_db),
):
return list_cli_targets(db, source=source, keyword=keyword, page=page, page_size=page_size)
if table_filters.field:
return list_cli_target_filter_options(db, field=table_filters.field, source=source, keyword=keyword)
filters = table_filters.filters
if set(filters) - {"source", "name", "ip", "vendor", "deviceType", "connectStatus"}:
raise HTTPException(status_code=400, detail="invalid_filter_field")
return list_cli_targets(
db,
source=source,
keyword=keyword,
source_filter=filters.get("source", {}).get("values", source_filter),
source_filter_mode=filters.get("source", {}).get("mode", source_filter_mode),
name_filter=filters.get("name", {}).get("values", name_filter),
name_filter_mode=filters.get("name", {}).get("mode", name_filter_mode),
ip_filter=filters.get("ip", {}).get("values", ip_filter),
ip_filter_mode=filters.get("ip", {}).get("mode", ip_filter_mode),
vendor_filter=filters.get("vendor", {}).get("values", vendor_filter),
vendor_filter_mode=filters.get("vendor", {}).get("mode", vendor_filter_mode),
device_type_filter=filters.get("deviceType", {}).get("values", device_type_filter),
device_type_filter_mode=filters.get("deviceType", {}).get("mode", device_type_filter_mode),
connect_status_filter=filters.get("connectStatus", {}).get("values", connect_status_filter),
connect_status_filter_mode=filters.get("connectStatus", {}).get("mode", connect_status_filter_mode),
page=page,
page_size=page_size,
)
@router.get("/targets/filter-options")
def api_list_cli_target_filter_options(
field: str = Query(),
source: str = Query(default="inventory"),
keyword: str | None = Query(default=None),
db: Session = Depends(get_db),
):
return list_cli_target_filter_options(db, field=field, source=source, keyword=keyword)
@router.get("/ume-overrides/{ume_ne_id}")

View file

@ -4,6 +4,7 @@ from datetime import datetime
from typing import Any
from fastapi import HTTPException
from sqlalchemy import func
from sqlalchemy.orm import Session
from .cli_schemas import (
@ -279,19 +280,66 @@ def list_cli_targets(
*,
source: str = "all",
keyword: str | None = None,
source_filter: list[str] | None = None,
source_filter_mode: str | None = None,
name_filter: list[str] | None = None,
name_filter_mode: str | None = None,
ip_filter: list[str] | None = None,
ip_filter_mode: str | None = None,
vendor_filter: list[str] | None = None,
vendor_filter_mode: str | None = None,
device_type_filter: list[str] | None = None,
device_type_filter_mode: str | None = None,
connect_status_filter: list[str] | None = None,
connect_status_filter_mode: str | None = None,
page: int = 1,
page_size: int = 50,
) -> dict[str, Any]:
from .device_types import WEBCRT_NE_SOURCE
src = str(source or "all").strip().lower()
if src not in ("managed", "ume", "webcrt", "all"):
if src not in ("managed", "ume", "webcrt", "inventory", "all"):
raise HTTPException(status_code=400, detail="invalid_source")
ready = cli_profile_ready(db)
page = max(1, int(page or 1))
page_size = max(1, min(500, int(page_size or 50)))
offset = (page - 1) * page_size
kw = str(keyword or "").strip()
column_filters: dict[str, list[str] | None] = {
"source": source_filter,
"name": name_filter,
"ip": ip_filter,
"vendor": vendor_filter,
"device_type": device_type_filter,
"connect_status": connect_status_filter,
}
filter_modes = {
"source": str(source_filter_mode or ""),
"name": str(name_filter_mode or ""),
"ip": str(ip_filter_mode or ""),
"vendor": str(vendor_filter_mode or ""),
"device_type": str(device_type_filter_mode or ""),
"connect_status": str(connect_status_filter_mode or ""),
}
def _source_allowed(row_source: str) -> bool:
mode = filter_modes["source"]
values = column_filters["source"] or []
if mode == "include":
return row_source in values
if mode == "exclude":
return row_source not in values
return True
def _apply_value_filter(stmt: Any, field: str, expression: Any) -> Any:
mode = filter_modes[field]
if mode not in ("include", "exclude"):
return stmt
values = column_filters[field] or []
normalized = func.coalesce(expression, "")
if mode == "include":
return stmt.filter(normalized.in_(values)) if values else stmt.filter(False)
return stmt.filter(~normalized.in_(values)) if values else stmt
default_profile = get_default_profile(db)
default_hop = bool(getattr(default_profile, "hop_enabled", False)) if default_profile else False
@ -351,7 +399,8 @@ def list_cli_targets(
return stmt
like = f"%{kw}%"
return stmt.filter(
ManagedNE.name.ilike(like)
ManagedNE.id.ilike(like)
| ManagedNE.name.ilike(like)
| ManagedNE.ip_address.ilike(like)
| ManagedNE.username.ilike(like)
| ManagedNE.tags.ilike(like)
@ -362,16 +411,42 @@ def list_cli_targets(
def _inventory_managed_query():
# Asset inventory for WebCRT: exclude Quick-Connect (source=webcrt) rows.
stmt = db.query(ManagedNE).filter(ManagedNE.source != WEBCRT_NE_SOURCE)
return _apply_kw(stmt).order_by(ManagedNE.updated_at.desc())
stmt = _apply_kw(stmt)
if not _source_allowed("managed"):
stmt = stmt.filter(False)
stmt = _apply_value_filter(
stmt,
"name",
func.coalesce(func.nullif(ManagedNE.name, ""), ManagedNE.ip_address),
)
stmt = _apply_value_filter(stmt, "ip", ManagedNE.ip_address)
stmt = _apply_value_filter(stmt, "vendor", ManagedNE.vendor)
stmt = _apply_value_filter(stmt, "device_type", ManagedNE.device_type)
stmt = _apply_value_filter(stmt, "connect_status", ManagedNE.connect_status)
return stmt.order_by(ManagedNE.updated_at.desc())
def _webcrt_query():
stmt = db.query(ManagedNE).filter(ManagedNE.source == WEBCRT_NE_SOURCE)
return _apply_kw(stmt).order_by(ManagedNE.updated_at.desc())
stmt = _apply_kw(stmt)
if not _source_allowed("webcrt"):
stmt = stmt.filter(False)
stmt = _apply_value_filter(
stmt,
"name",
func.coalesce(func.nullif(ManagedNE.name, ""), ManagedNE.ip_address),
)
stmt = _apply_value_filter(stmt, "ip", ManagedNE.ip_address)
stmt = _apply_value_filter(stmt, "vendor", ManagedNE.vendor)
stmt = _apply_value_filter(stmt, "device_type", ManagedNE.device_type)
stmt = _apply_value_filter(stmt, "connect_status", ManagedNE.connect_status)
return stmt.order_by(ManagedNE.updated_at.desc())
def _ume_query():
stmt = db.query(UmeInventoryNE, UmeCliOverride).outerjoin(
UmeCliOverride, UmeInventoryNE.ne_id == UmeCliOverride.ume_ne_id
)
if not _source_allowed("ume"):
stmt = stmt.filter(False)
if kw:
like = f"%{kw}%"
stmt = stmt.filter(
@ -381,8 +456,46 @@ def list_cli_targets(
| UmeInventoryNE.ip_address.ilike(like)
| UmeInventoryNE.host_name.ilike(like)
)
ume_name = func.coalesce(
func.nullif(UmeInventoryNE.user_label, ""),
func.nullif(UmeInventoryNE.ne_name, ""),
func.nullif(UmeInventoryNE.host_name, ""),
func.nullif(UmeInventoryNE.ip_address, ""),
UmeInventoryNE.ne_id,
)
stmt = _apply_value_filter(stmt, "name", ume_name)
stmt = _apply_value_filter(stmt, "ip", UmeInventoryNE.ip_address)
stmt = _apply_value_filter(stmt, "vendor", UmeInventoryNE.vendor)
stmt = _apply_value_filter(stmt, "device_type", UmeInventoryNE.ne_type)
stmt = _apply_value_filter(
stmt,
"connect_status",
func.coalesce(UmeCliOverride.connect_status, "unknown"),
)
return stmt.order_by(UmeInventoryNE.ne_id.asc())
if src == "inventory":
# Network Management inventory includes managed assets and UME inventory,
# but not transient WebCRT quick-connect sessions.
mq = _inventory_managed_query()
uq = _ume_query()
m_total = int(mq.count())
u_total = int(uq.count())
total = m_total + u_total
items: list[dict[str, Any]] = []
if offset < m_total:
take = min(page_size, m_total - offset)
items.extend(_managed_item(row, list_source="managed") for row in mq.offset(offset).limit(take).all())
remaining = page_size - take
if remaining > 0:
items.extend(_ume_item(inv, ov) for inv, ov in uq.offset(0).limit(remaining).all())
else:
ume_offset = max(0, offset - m_total)
items.extend(_ume_item(inv, ov) for inv, ov in uq.offset(ume_offset).limit(page_size).all())
return {"items": items, "total": total, "page": page, "page_size": page_size}
if src == "managed":
mq = _inventory_managed_query()
total = int(mq.count())
@ -443,6 +556,118 @@ def list_cli_targets(
return {"items": items, "total": total, "page": page, "page_size": page_size}
def list_cli_target_filter_options(
db: Session,
*,
field: str,
source: str = "inventory",
keyword: str | None = None,
) -> dict[str, Any]:
from .device_types import WEBCRT_NE_SOURCE
field_map = {
"source": "source",
"name": "name",
"ip": "ip",
"vendor": "vendor",
"deviceType": "device_type",
"connectStatus": "connect_status",
}
if field not in field_map:
raise HTTPException(status_code=400, detail="invalid_filter_field")
src = str(source or "inventory").strip().lower()
if src not in ("managed", "ume", "webcrt", "inventory", "all"):
raise HTTPException(status_code=400, detail="invalid_source")
include_managed = src in ("managed", "inventory", "all")
include_webcrt = src in ("webcrt", "all")
include_ume = src in ("ume", "inventory", "all")
kw = str(keyword or "").strip()
managed = db.query(ManagedNE)
if src in ("managed", "inventory"):
managed = managed.filter(ManagedNE.source != WEBCRT_NE_SOURCE)
elif src == "webcrt":
managed = managed.filter(ManagedNE.source == WEBCRT_NE_SOURCE)
elif src == "all":
managed = managed.filter(ManagedNE.source != WEBCRT_NE_SOURCE)
ume = db.query(UmeInventoryNE)
ume_status = db.query(UmeInventoryNE, UmeCliOverride).outerjoin(
UmeCliOverride, UmeInventoryNE.ne_id == UmeCliOverride.ume_ne_id
)
webcrt = db.query(ManagedNE).filter(ManagedNE.source == WEBCRT_NE_SOURCE)
def _apply_managed_keyword(query: Any) -> Any:
if not kw:
return query
like = f"%{kw}%"
return query.filter(
ManagedNE.id.ilike(like)
| ManagedNE.name.ilike(like)
| ManagedNE.ip_address.ilike(like)
| ManagedNE.username.ilike(like)
| ManagedNE.tags.ilike(like)
| ManagedNE.vendor.ilike(like)
| ManagedNE.device_type.ilike(like)
)
def _apply_ume_keyword(query: Any) -> Any:
if not kw:
return query
like = f"%{kw}%"
return query.filter(
UmeInventoryNE.ne_id.ilike(like)
| UmeInventoryNE.ne_name.ilike(like)
| UmeInventoryNE.user_label.ilike(like)
| UmeInventoryNE.ip_address.ilike(like)
| UmeInventoryNE.host_name.ilike(like)
)
managed = _apply_managed_keyword(managed)
webcrt = _apply_managed_keyword(webcrt)
ume = _apply_ume_keyword(ume)
ume_status = _apply_ume_keyword(ume_status)
if field == "source":
values: set[str] = set()
if include_managed and managed.count():
values.add("managed")
if include_webcrt and webcrt.count():
values.add("webcrt")
if include_ume and ume.count():
values.add("ume")
else:
managed_expr = {
"name": func.coalesce(func.nullif(ManagedNE.name, ""), ManagedNE.ip_address),
"ip": ManagedNE.ip_address,
"vendor": ManagedNE.vendor,
"device_type": ManagedNE.device_type,
"connect_status": ManagedNE.connect_status,
}.get(field_map[field])
ume_expr = {
"name": func.coalesce(
func.nullif(UmeInventoryNE.user_label, ""),
func.nullif(UmeInventoryNE.ne_name, ""),
func.nullif(UmeInventoryNE.host_name, ""),
func.nullif(UmeInventoryNE.ip_address, ""),
UmeInventoryNE.ne_id,
),
"ip": UmeInventoryNE.ip_address,
"vendor": UmeInventoryNE.vendor,
"device_type": UmeInventoryNE.ne_type,
"connect_status": func.coalesce(UmeCliOverride.connect_status, "unknown"),
}.get(field_map[field])
values = set()
if include_managed and managed_expr is not None:
values.update(str(row[0] or "") for row in managed.with_entities(managed_expr).distinct().all())
if include_webcrt and managed_expr is not None:
values.update(str(row[0] or "") for row in webcrt.with_entities(managed_expr).distinct().all())
if include_ume and ume_expr is not None:
values.update(str(row[0] or "") for row in ume_status.with_entities(ume_expr).distinct().all())
return {"field": field, "options": sorted(values, key=str.casefold)}
def cli_meta(db: Session) -> dict[str, Any]:
return {
"credentials_configured": credentials_configured(),

View file

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

View file

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

View file

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

View file

@ -5,9 +5,11 @@ from __future__ import annotations
from datetime import datetime, timedelta
from fastapi import HTTPException
from sqlalchemy import or_
from sqlalchemy import String, cast, or_
from sqlalchemy.orm import Session
from .table_filters import TableFilterParams, filter_query
from .topology_discover_common import discover_item_expressions
from .lldp_collect_schemas import (
LldpCollectDashboardOut,
LldpCollectJobSummary,
@ -488,6 +490,7 @@ def list_jobs(
page_size: int = 20,
status: str = "",
keyword: str = "",
table_filters: TableFilterParams | None = None,
) -> dict:
page = max(1, int(page or 1))
page_size = max(1, min(100, int(page_size or 20)))
@ -506,6 +509,15 @@ def list_jobs(
TopoDiscoverJob.trigger_mode.ilike(like),
)
)
job = TopoDiscoverJob
q, options = filter_query(q, {
"id": job.id, "trigger": job.trigger_mode, "scope": job.scope, "status": job.status,
"progress": cast(job.done, String) + "/" + cast(job.total, String),
"edges": cast(job.edges_added, String) + "/" + cast(job.edges_updated, String),
"missingDelta": job.edges_stale, "started": job.started_at, "ended": job.ended_at,
}, table_filters)
if options is not None:
return options
q = q.order_by(TopoDiscoverJob.created_at.desc())
total = int(q.count())
rows = q.offset((page - 1) * page_size).limit(page_size).all()
@ -532,7 +544,14 @@ def get_job_detail(
page_size: int | None = None,
item_status: str = "",
item_keyword: str = "",
table_filters: TableFilterParams | None = None,
) -> dict:
if table_filters and table_filters.field:
if db.get(TopoDiscoverJob, job_id) is None:
raise HTTPException(status_code=404, detail="discover_job_not_found")
q = db.query(TopoDiscoverJobItem).filter(TopoDiscoverJobItem.job_id == job_id)
_, options = filter_query(q, discover_item_expressions(), table_filters)
return options
return get_discover_job(
db,
job_id,
@ -540,4 +559,5 @@ def get_job_detail(
page_size=page_size,
item_status=item_status,
item_keyword=item_keyword,
table_filters=table_filters,
).model_dump()

55
netx_api/table_filters.py Normal file
View file

@ -0,0 +1,55 @@
"""Shared exact-value column filters, applied before pagination."""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import Any
from fastapi import HTTPException, Query
from sqlalchemy import String, cast, func
@dataclass
class TableFilterParams:
filters: dict[str, dict[str, Any]] = field(default_factory=dict)
field: str = ""
def table_filter_params(
column_filters: str = Query(default=""),
filter_field: str = Query(default=""),
) -> TableFilterParams:
try:
filters = json.loads(column_filters) if column_filters else {}
if not isinstance(filters, dict):
raise ValueError()
for value in filters.values():
if not isinstance(value, dict) or value.get("mode") not in ("include", "exclude"):
raise ValueError()
values = value.get("values")
if not isinstance(values, list) or any(not isinstance(v, str) for v in values):
raise ValueError()
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="invalid_column_filters") from None
return TableFilterParams(filters, filter_field)
def filter_query(query: Any, expressions: dict[str, Any], params: TableFilterParams | None):
"""Return the filtered query and, when requested, a distinct-value response."""
params = params or TableFilterParams()
if (set(params.filters) | ({params.field} if params.field else set())) - expressions.keys():
raise HTTPException(status_code=400, detail="invalid_filter_field")
for key, value in params.filters.items():
if key == params.field:
continue
expression = func.coalesce(cast(expressions[key], String), "")
values = value["values"]
if value["mode"] == "include":
query = query.filter(expression.in_(values)) if values else query.filter(False)
elif values:
query = query.filter(~expression.in_(values))
if params.field:
expression = func.coalesce(cast(expressions[params.field], String), "")
rows = query.order_by(None).with_entities(expression).distinct().all()
return query, {"field": params.field, "options": sorted({str(row[0]) for row in rows})}
return query, None

View file

@ -4,9 +4,10 @@ from __future__ import annotations
from typing import Any
from fastapi import HTTPException
from sqlalchemy import or_
from sqlalchemy import String, case, cast, func, or_
from sqlalchemy.orm import Session
from .table_filters import TableFilterParams, filter_query
from .cli_resolve import get_default_profile, infer_device_type_vendor
from .device_types import is_placeholder_ne_source
from .models import ManagedNE, TopoDiscoverJob, TopoDiscoverJobItem, UmeInventoryNE
@ -42,6 +43,17 @@ def _managed_target_dict(ne: ManagedNE) -> dict[str, str]:
}
def discover_item_expressions() -> dict[str, Any]:
item = TopoDiscoverJobItem
failed = or_(item.ok.is_(False), item.parser_stub.is_(True), item.error.in_(["parser_stub", "empty_cli_output", "vendor_or_device_type_required"]))
return {
"name": func.coalesce(func.nullif(item.ne_name, ""), item.ne_id), "ip": item.ne_ip,
"status": case((failed, "fail"), (item.unmatched_count > 0, "warn"), else_="ok"),
"neighbors": item.neighbors, "error": item.error,
"edges": cast(item.edges_added, String) + "/" + cast(item.edges_updated, String),
}
def _job_out(
db: Session,
job: TopoDiscoverJob,
@ -51,6 +63,7 @@ def _job_out(
page_size: int | None = None,
item_status: str = "",
item_keyword: str = "",
table_filters: TableFilterParams | None = None,
) -> FabricDiscoverJobOut:
items_out: list[FabricDiscoverJobItemOut] = []
items_total = 0
@ -93,6 +106,7 @@ def _job_out(
TopoDiscoverJobItem.error.ilike(like),
)
)
q, _ = filter_query(q, discover_item_expressions(), table_filters)
items_total = int(q.count())
if page is not None and page_size is not None:
items_page = max(1, int(page or 1))
@ -157,6 +171,7 @@ def get_discover_job(
page_size: int | None = None,
item_status: str = "",
item_keyword: str = "",
table_filters: TableFilterParams | None = None,
) -> FabricDiscoverJobOut:
job = db.get(TopoDiscoverJob, str(job_id or "").strip())
if job is None:
@ -168,6 +183,7 @@ def get_discover_job(
page_size=page_size,
item_status=item_status,
item_keyword=item_keyword,
table_filters=table_filters,
)

View file

@ -8,10 +8,11 @@ from typing import Any
from uuid import uuid4
from fastapi import HTTPException
from sqlalchemy import and_, func, or_, text
from sqlalchemy import and_, case, func, or_, text
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from .table_filters import TableFilterParams, filter_query
from .cli_resolve import get_default_profile, infer_device_type_vendor
from .device_types import LLDP_DISCOVERED_NE_SOURCE, WEBCRT_NE_SOURCE
from .models import (
@ -275,6 +276,7 @@ def list_fabric_nodes(
region_folder_id: str = "",
unmatched: str = "",
link_status: str = "",
table_filters: TableFilterParams | None = None,
page: int = 1,
page_size: int = PAGE_DEFAULT,
) -> dict[str, Any]:
@ -375,6 +377,16 @@ def list_fabric_nodes(
and_(TopoFabricNode.managed_ne_id.isnot(None), TopoFabricNode.managed_ne_id != ""),
and_(TopoFabricNode.ume_ne_id.isnot(None), TopoFabricNode.ume_ne_id != ""),
)
has_managed = func.coalesce(TopoFabricNode.managed_ne_id, "") != ""
has_ume = func.coalesce(TopoFabricNode.ume_ne_id, "") != ""
q, options = filter_query(q, {
"name": func.coalesce(func.nullif(TopoFabricNode.name, ""), TopoFabricNode.id),
"ip": TopoFabricNode.ip, "level": TopoFabricNode.level,
"region": TopoFabricNode.region_folder_id,
"link": case((and_(has_managed, has_ume), "both"), (has_managed, "managed"), (has_ume, "ume"), else_="orphaned"),
}, table_filters)
if options is not None:
return options
total = int(q.count())
rows = (
q.order_by(TopoFabricNode.name.asc())
@ -399,6 +411,7 @@ def list_fabric_edges(
status: str = "",
source: str = "",
keyword: str = "",
table_filters: TableFilterParams | None = None,
page: int = 1,
page_size: int = PAGE_DEFAULT,
) -> dict[str, Any]:
@ -438,6 +451,23 @@ def list_fabric_edges(
TopoFabricEdge.b_node_id.in_(matched_ids),
)
)
from sqlalchemy.orm import aliased
a = aliased(TopoFabricNode)
b = aliased(TopoFabricNode)
q = q.outerjoin(a, TopoFabricEdge.a_node_id == a.id).outerjoin(b, TopoFabricEdge.b_node_id == b.id)
status_expr = case((TopoFabricEdge.status == "stale", "missing"), else_=TopoFabricEdge.status)
miss = func.coalesce(TopoFabricEdge.attrs["miss_count"].as_integer(), 0)
q, options = filter_query(q, {
"status": status_expr,
"aSide": func.coalesce(func.nullif(a.name, ""), func.nullif(a.ip, ""), func.substr(TopoFabricEdge.a_node_id, 1, 8)),
"bSide": func.coalesce(func.nullif(b.name, ""), func.nullif(b.ip, ""), func.substr(TopoFabricEdge.b_node_id, 1, 8)),
"aPort": TopoFabricEdge.a_port, "bPort": TopoFabricEdge.b_port,
"source": case((TopoFabricEdge.source == "stale", "lldp"), else_=func.coalesce(func.nullif(TopoFabricEdge.source, ""), "lldp")),
"lastSeen": TopoFabricEdge.last_seen_at,
"missCount": case((miss != 0, miss), (status_expr == "missing", 1), else_=0),
}, table_filters)
if options is not None:
return options
total = int(q.count())
rows = (
q.order_by(TopoFabricEdge.updated_at.desc())

View file

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

View file

@ -10,6 +10,8 @@ from sqlalchemy import func, or_
from sqlalchemy.orm import Session
from .config import settings
from .table_filters import TableFilterParams, filter_query
from .table_filters import table_filter_params
from .db import get_db
from .key_alert_config import (
get_key_alert_monitor_config,
@ -78,6 +80,7 @@ def ume_list_alarms(
time_to: str | None = Query(default=None, description="Filter by last_seen_at <="),
page: int = Query(default=1, ge=1),
page_size: int = Query(default=50, ge=1, le=500),
table_filters: TableFilterParams = Depends(table_filter_params),
db: Session = Depends(get_db),
) -> dict[str, Any]:
stmt = db.query(UmeAlarmCurrent, UmeInventoryNE).outerjoin(
@ -113,6 +116,15 @@ def ume_list_alarms(
stmt = stmt.filter(UmeAlarmCurrent.last_seen_at >= dt_from.replace(tzinfo=None))
if dt_to:
stmt = stmt.filter(UmeAlarmCurrent.last_seen_at <= dt_to.replace(tzinfo=None))
stmt, options = filter_query(stmt, {
"time": UmeAlarmCurrent.time_created, "severity": UmeAlarmCurrent.perceived_severity,
"neId": UmeAlarmCurrent.ne_id,
"hostName": func.coalesce(func.nullif(func.trim(UmeAlarmCurrent.host_name), ""), func.trim(UmeInventoryNE.host_name)),
"neType": UmeInventoryNE.ne_type, "cause": UmeAlarmCurrent.native_probable_cause,
"cleared": UmeAlarmCurrent.is_cleared,
}, table_filters)
if options is not None:
return options
total = int(stmt.count())
rows = (
stmt.order_by(