mirror of
https://github.com/hansjone/netx.git
synced 2026-10-14 18:30:47 +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 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}")
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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]}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
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 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,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue