netx/netx_api/topology_schemas.py
oliver e0503df8a5 Raise runtime defaults for multi-user shared-server deployments.
Size DB pool, CLI budget, and WebCRT session caps for concurrent operators while keeping hard ceilings and env overrides.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-02 18:11:12 +08:00

462 lines
13 KiB
Python

"""Pydantic schemas for fabric topology + views (final model)."""
from __future__ import annotations
from datetime import datetime
from typing import Any
from pydantic import BaseModel, Field
# ---------------------------------------------------------------------------
# Fabric
# ---------------------------------------------------------------------------
class FabricNodeOut(BaseModel):
id: str
managed_ne_id: str = ""
ume_ne_id: str = ""
name: str = ""
ip: str = ""
vendor: str = ""
device_type: str = ""
role: str = ""
region_folder_id: str | None = None
role_source: str = ""
region_source: str = ""
attrs: dict[str, Any] = Field(default_factory=dict)
last_seen_at: datetime | None = None
# Inventory link diagnostics (list/enrich); empty when not enriched.
link_status: str = "" # managed | ume | both | orphaned
managed_alive: bool = False
ume_alive: bool = False
managed_source: str = "" # manual | ume_sync | lldp | webcrt | …
deletable: bool = False
class FabricEdgeOut(BaseModel):
id: str
layer: str = "physical"
a_node_id: str
b_node_id: str
a_port: str = ""
b_port: str = ""
a_name: str = ""
b_name: str = ""
a_ip: str = ""
b_ip: str = ""
source: str = "lldp"
status: str = "active"
attrs: dict[str, Any] = Field(default_factory=dict)
discovered_at: datetime | None = None
last_seen_at: datetime | None = None
updated_at: datetime | None = None
class FabricSummaryOut(BaseModel):
node_count: int = 0
edge_count: int = 0
edge_active: int = 0
edge_stale: int = 0 # legacy alias of edge_missing
edge_missing: int = 0
last_discover_at: datetime | None = None
updated_at: datetime | None = None
class FabricNeighborhoodOut(BaseModel):
center_node_id: str
depth: int = 1
nodes: list[FabricNodeOut] = Field(default_factory=list)
edges: list[FabricEdgeOut] = Field(default_factory=list)
class FabricDiscoverRequest(BaseModel):
"""Start LLDP discovery into fabric (no CDP)."""
scope: str = Field(default="ne_ids", description="all_inventory | ne_ids")
ne_ids: list[str] = Field(
default_factory=list,
description="Legacy mixed ids (managed first, then ume). Prefer managed_ne_ids/ume_ne_ids.",
)
managed_ne_ids: list[str] = Field(default_factory=list)
ume_ne_ids: list[str] = Field(default_factory=list)
auto_add_unmatched: bool = Field(
default=True,
description="Create SSH placeholder ManagedNEs for LLDP neighbors not in inventory",
)
concurrency: int = Field(default=4, ge=1, le=32)
trigger_mode: str = Field(default="manual", description="manual | schedule | topology")
class FabricDiscoverUnmatched(BaseModel):
remote_name: str = ""
remote_ip: str = ""
local_port: str = ""
remote_port: str = ""
class FabricDiscoverJobItemOut(BaseModel):
id: str
job_id: str
ne_id: str = ""
ume_ne_id: str = ""
fabric_node_id: str = ""
ne_name: str = ""
ne_ip: str = ""
ok: bool = False
command: str = ""
neighbors: int = 0
edges_added: int = 0
edges_updated: int = 0
unmatched_count: int = 0
unmatched: list[FabricDiscoverUnmatched] = Field(default_factory=list)
parser_key: str = ""
parser_stub: bool = False
error: str = ""
raw_preview: str = ""
class FabricDiscoverJobOut(BaseModel):
id: str
scope: str
trigger_mode: str = "manual"
status: str
total: int = 0
done: int = 0
edges_added: int = 0
edges_updated: int = 0
edges_stale: int = 0 # legacy alias of edges_missing
edges_missing: int = 0
error: str = ""
started_at: datetime | None = None
ended_at: datetime | None = None
items: list[FabricDiscoverJobItemOut] = Field(default_factory=list)
# ---------------------------------------------------------------------------
# Folders (tree grouping) + Views (leaf canvases)
# ---------------------------------------------------------------------------
class TopologyFolderCreate(BaseModel):
name: str = Field(min_length=1, max_length=256)
kind: str = Field(default="region", description="region only from API")
parent_id: str | None = None
sort_order: int = 0
class TopologyFolderUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=256)
parent_id: str | None = None
sort_order: int | None = None
class TopologyFolderOut(BaseModel):
id: str
parent_id: str = ""
kind: str
name: str
sort_order: int = 0
is_system: bool = False
created_at: datetime | None = None
updated_at: datetime | None = None
class TopologyViewCreate(BaseModel):
name: str = Field(min_length=1, max_length=256)
remark: str = Field(default="", max_length=1024)
filter: dict[str, Any] = Field(default_factory=dict)
folder_id: str = Field(..., min_length=1, description="Site/region folder id (required)")
kind: str = Field(default="custom", description="physical | custom")
role: str = Field(
default="core",
description="Optional filter preset label (legacy); not a tree level",
)
sort_order: int = 0
class TopologyViewUpdate(BaseModel):
name: str | None = Field(default=None, min_length=1, max_length=256)
remark: str | None = Field(default=None, max_length=1024)
filter: dict[str, Any] | None = None
viewport: dict[str, Any] | None = None
folder_id: str | None = None
kind: str | None = None
role: str | None = None
sort_order: int | None = None
class TopologyViewOut(BaseModel):
id: str
name: str
remark: str = ""
folder_id: str = ""
kind: str = "custom"
role: str = "core"
sort_order: int = 0
filter: dict[str, Any] = Field(default_factory=dict)
viewport: dict[str, Any] = Field(default_factory=dict)
node_count: int = 0
created_at: datetime | None = None
updated_at: datetime | None = None
class TopologyTreeViewOut(BaseModel):
id: str
name: str
kind: str = "custom"
role: str = "core"
sort_order: int = 0
node_count: int = 0
updated_at: datetime | None = None
class TopologyTreeFolderOut(BaseModel):
id: str
parent_id: str = ""
kind: str
name: str
sort_order: int = 0
is_system: bool = False
views: list[TopologyTreeViewOut] = Field(default_factory=list)
children: list["TopologyTreeFolderOut"] = Field(default_factory=list)
class TopologyTreeOut(BaseModel):
root: TopologyTreeFolderOut | None = None
class ViewPopulateRequest(BaseModel):
"""Fill a leaf view from membership rules (optional dry-run)."""
dry_run: bool = False
membership: dict[str, Any] | None = None
freeze_after: bool = True
class ViewNodeIn(BaseModel):
fabric_node_id: str = Field(min_length=1, max_length=64)
x: float = 0.0
y: float = 0.0
label: str = ""
locked: bool = False
class ViewNodeOut(BaseModel):
fabric_node_id: str
managed_ne_id: str = ""
ume_ne_id: str = ""
label: str = ""
x: float = 0.0
y: float = 0.0
locked: bool = False
name: str = ""
ip: str = ""
vendor: str = ""
device_type: str = ""
connect_status: str = ""
class ViewEdgeOut(BaseModel):
id: str
a_node_id: str
b_node_id: str
a_port: str = ""
b_port: str = ""
source: str = "lldp"
status: str = "active"
layer: str = "physical"
stroke_color: str = ""
stroke_width: int = 0
line_style: str = ""
discovered_at: datetime | None = None
class TopologyViewGraphOut(BaseModel):
view: TopologyViewOut
nodes: list[ViewNodeOut]
edges: list[ViewEdgeOut]
truncated: bool = False
truncate_reason: str = ""
outside_peers: list[dict[str, str]] = Field(default_factory=list)
class ViewPopulateOut(BaseModel):
view_id: str
dry_run: bool = False
candidate_count: int = 0
would_add: int = 0
added: int = 0
max_nodes: int = 0
truncated: bool = False
outside_peers: list[dict[str, str]] = Field(default_factory=list)
graph: TopologyViewGraphOut | None = None
class ViewPositionsPatch(BaseModel):
positions: list[ViewNodeIn] = Field(default_factory=list)
class ViewNodesAdd(BaseModel):
"""Add inventory NEs onto a view (creates fabric nodes as needed)."""
managed_ne_ids: list[str] = Field(default_factory=list)
ume_ne_ids: list[str] = Field(default_factory=list)
fabric_node_ids: list[str] = Field(
default_factory=list,
description="Place existing fabric nodes onto the view",
)
# Optional initial positions keyed by managed/ume id
layout: str = Field(default="grid", description="grid | keep")
class ViewEdgeStylePatch(BaseModel):
fabric_edge_id: str
stroke_color: str = ""
stroke_width: int = Field(default=0, ge=0, le=12)
line_style: str = Field(default="", max_length=16)
class FabricManualEdgeIn(BaseModel):
a_node_id: str = Field(min_length=1, max_length=64)
b_node_id: str = Field(min_length=1, max_length=64)
a_port: str = ""
b_port: str = ""
# ---------------------------------------------------------------------------
# Classify rules + slices + search
# ---------------------------------------------------------------------------
class ClassifyRuleOut(BaseModel):
id: str
scope: str = "role"
name: str = ""
pattern: str = ""
match_field: str = "name"
priority: int = 100
enabled: bool = True
payload: dict[str, Any] = Field(default_factory=dict)
remark: str = ""
created_at: datetime | None = None
updated_at: datetime | None = None
class ClassifyRuleCreate(BaseModel):
scope: str = Field(default="role", description="role | region")
name: str = ""
pattern: str
match_field: str = "name"
priority: int = 100
enabled: bool = True
payload: dict[str, Any] = Field(default_factory=dict)
remark: str = ""
class ClassifyRuleUpdate(BaseModel):
name: str | None = None
pattern: str | None = None
match_field: str | None = None
priority: int | None = None
enabled: bool | None = None
payload: dict[str, Any] | None = None
remark: str | None = None
class ClassifyPreviewOut(BaseModel):
total_nodes: int = 0
role_matched: int = 0
role_unmatched: int = 0
role_conflicts: int = 0
region_matched: int = 0
region_unmatched: int = 0
region_conflicts: int = 0
role_samples: list[dict[str, Any]] = Field(default_factory=list)
region_samples: list[dict[str, Any]] = Field(default_factory=list)
unmatched_samples: list[dict[str, Any]] = Field(default_factory=list)
class ClassifyApplyOut(BaseModel):
role_updated: int = 0
region_updated: int = 0
skipped_manual: int = 0
total_nodes: int = 0
class FabricNodeTagPatch(BaseModel):
role: str | None = None
region_folder_id: str | None = None
class FabricNodesMatchRequest(BaseModel):
"""Ephemeral regex match over fabric inventory (not persisted as rules)."""
pattern: str
match_field: str = "name"
sample_limit: int = Field(default=50, ge=1, le=200)
class FabricNodesMatchOut(BaseModel):
pattern: str
match_field: str = "name"
total_matched: int = 0
samples: list[dict[str, Any]] = Field(default_factory=list)
fabric_node_ids: list[str] = Field(default_factory=list)
class FabricNodesBulkTagRequest(BaseModel):
"""Assign role/region to explicit ids or to an ephemeral regex match."""
fabric_node_ids: list[str] = Field(default_factory=list)
pattern: str = ""
match_field: str = "name"
role: str | None = None
region_folder_id: str | None = None
dry_run: bool = False
class FabricNodesBulkTagOut(BaseModel):
dry_run: bool = False
matched: int = 0
updated: int = 0
role: str | None = None
region_folder_id: str | None = None
samples: list[dict[str, Any]] = Field(default_factory=list)
class FabricNodesDeleteRequest(BaseModel):
fabric_node_ids: list[str] = Field(default_factory=list, min_length=1)
class FabricNodesDeleteOut(BaseModel):
deleted: int = 0
edges_deleted: int = 0
placements_deleted: int = 0
class SliceGenerateRequest(BaseModel):
folder_id: str
template: str = Field(description="core_only | core_agg | agg_access")
dry_run: bool = True
max_nodes: int = Field(default=300, ge=1, le=2000)
seed_physical_cores: bool = False
class SliceMapPlan(BaseModel):
name: str
role: str = "core"
seed_fabric_node_ids: list[str] = Field(default_factory=list)
member_fabric_node_ids: list[str] = Field(default_factory=list)
node_count: int = 0
class SliceGenerateOut(BaseModel):
folder_id: str
template: str
dry_run: bool = True
maps: list[SliceMapPlan] = Field(default_factory=list)
map_count: int = 0
overlap_node_count: int = 0
created_view_ids: list[str] = Field(default_factory=list)