mirror of
https://github.com/hansjone/netx.git
synced 2026-10-08 23:33:21 +08:00
Portrait stays on biz-monitor; cutover create/ensure owns purpose=cutover_hf slots with project collect override, multi-interval bindings, window pause, and industrial detail UX. Also expand ZTE collect/config parsers and TextFSM for route/BGP/L2VPN/VRRP coverage. Co-authored-by: Cursor <cursoragent@cursor.com>
442 lines
16 KiB
Python
442 lines
16 KiB
Python
"""CRUD for BizMonitorTemplate (cutover overlay on compare templates)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
from uuid import uuid4
|
|
|
|
from fastapi import HTTPException
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..models import BizCompareTemplate, BizMonitorTemplate
|
|
from ..timeutil import utcnow_naive
|
|
from ..biz_state import compare_service as cmp_svc
|
|
from ..biz_state.iface_normalize import default_zte_iface_normalize_rules
|
|
from .evaluate import PORT_METRIC_ID, PORT_STATUS_FIELDS
|
|
|
|
|
|
def _utcnow():
|
|
return utcnow_naive()
|
|
|
|
|
|
def _out(row: BizMonitorTemplate, compare_name: str = "", *, db: Session | None = None) -> dict[str, Any]:
|
|
collect = [str(x).strip() for x in (row.collect_metric_ids_json or []) if str(x).strip()]
|
|
effective = list(collect)
|
|
if not effective and db is not None:
|
|
ct = db.get(BizCompareTemplate, row.compare_template_id) if row.compare_template_id else None
|
|
if ct:
|
|
seen: list[str] = []
|
|
for s in cmp_svc.template_metrics(ct):
|
|
mid = str(s.get("metric_id") or "").strip()
|
|
if mid and mid not in seen:
|
|
seen.append(mid)
|
|
effective = seen
|
|
if not effective:
|
|
effective = [PORT_METRIC_ID]
|
|
return {
|
|
"id": row.id,
|
|
"name": row.name,
|
|
"compare_template_id": row.compare_template_id or "",
|
|
"compare_template_name": compare_name,
|
|
"collect_metric_ids": collect,
|
|
"collect_metric_ids_effective": effective,
|
|
"defaults": dict(row.defaults_json or {}),
|
|
"sheet_overrides": list(row.sheet_overrides_json or []),
|
|
"note": row.note or "",
|
|
"created_at": row.created_at.isoformat() + "Z" if row.created_at else None,
|
|
"updated_at": row.updated_at.isoformat() + "Z" if row.updated_at else None,
|
|
}
|
|
|
|
|
|
def _compare_name_map(db: Session) -> dict[str, str]:
|
|
rows = db.query(BizCompareTemplate.id, BizCompareTemplate.name).all()
|
|
return {str(r.id): str(r.name or "") for r in rows}
|
|
|
|
|
|
def ensure_port_compare_template(db: Session) -> BizCompareTemplate:
|
|
"""Built-in HOW template for interface_brief (matches former port_sheet_def)."""
|
|
name = "Cutover port status"
|
|
row = db.query(BizCompareTemplate).filter(BizCompareTemplate.name == name).one_or_none()
|
|
sheet = {
|
|
"metric_id": PORT_METRIC_ID,
|
|
"key_fields": ["interface"],
|
|
"iface_fields": ["interface"],
|
|
"compare_fields": list(PORT_STATUS_FIELDS),
|
|
"display_fields": ["interface", *PORT_STATUS_FIELDS, "description"],
|
|
"row_filters": [],
|
|
"field_rules": [],
|
|
}
|
|
if row:
|
|
if not cmp_svc.template_iface_normalize(row):
|
|
cmp_svc._set_template_iface_normalize(row, default_zte_iface_normalize_rules()) # noqa: SLF001
|
|
row.updated_at = _utcnow()
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|
|
row = BizCompareTemplate(
|
|
id=uuid4().hex,
|
|
name=name,
|
|
note="Built-in port status sheet for cutover monitor (admin/phy/prot)",
|
|
created_at=_utcnow(),
|
|
updated_at=_utcnow(),
|
|
)
|
|
cmp_svc._apply_sheets_to_row(row, [sheet]) # noqa: SLF001 — shared normalizer
|
|
cmp_svc._set_template_iface_normalize(row, default_zte_iface_normalize_rules()) # noqa: SLF001
|
|
db.add(row)
|
|
db.commit()
|
|
db.refresh(row)
|
|
return row
|
|
|
|
|
|
def _anomaly_both_gone() -> dict[str, Any]:
|
|
return {
|
|
"old_groups": [[{"type": "presence", "value": "removed"}]],
|
|
"new_groups": [[{"type": "presence", "value": "removed"}]],
|
|
}
|
|
|
|
|
|
def _anomaly_side_only(side: str, groups: list[list[dict[str, Any]]]) -> dict[str, Any]:
|
|
return {
|
|
"old_groups": groups if side == "old" else [],
|
|
"new_groups": groups if side == "new" else [],
|
|
}
|
|
|
|
|
|
def _default_anomaly_for_state(field: str, down_values: list[str]) -> list[dict[str, Any]]:
|
|
down = [{"type": "value", "field": field, "op": "in", "value": list(down_values)}]
|
|
gone = [{"type": "presence", "value": "removed"}]
|
|
return [
|
|
_anomaly_both_gone(),
|
|
_anomaly_side_only("old", [gone, down]),
|
|
_anomaly_side_only("new", [gone, down]),
|
|
]
|
|
|
|
|
|
def _default_anomaly_presence_only() -> list[dict[str, Any]]:
|
|
gone = [{"type": "presence", "value": "removed"}]
|
|
return [
|
|
_anomaly_both_gone(),
|
|
_anomaly_side_only("old", [gone]),
|
|
_anomaly_side_only("new", [gone]),
|
|
]
|
|
|
|
|
|
def _success_presence_migrate() -> dict[str, Any]:
|
|
"""Old gone → new appeared (no bare unchanged = false green)."""
|
|
return {
|
|
"old_groups": [[{"type": "presence", "value": "removed"}]],
|
|
"new_groups": [[{"type": "presence", "value": "added"}]],
|
|
}
|
|
|
|
|
|
def _success_stateful(
|
|
*,
|
|
old_down_groups: list[list[dict[str, Any]]],
|
|
new_up_conds: list[dict[str, Any]],
|
|
) -> dict[str, Any]:
|
|
"""Success requires healthy new side — bare unchanged alone is not enough."""
|
|
return {
|
|
"old_groups": [
|
|
[{"type": "presence", "value": "removed"}],
|
|
*old_down_groups,
|
|
],
|
|
"new_groups": [
|
|
[{"type": "presence", "value": "added"}, *new_up_conds],
|
|
[{"type": "presence", "value": "unchanged"}, *new_up_conds],
|
|
list(new_up_conds),
|
|
],
|
|
}
|
|
|
|
|
|
def preset_override_for_metric(metric_id: str, *, sheet_id: str = "") -> dict[str, Any]:
|
|
"""Default dual-verdict overlay for a compare-template sheet.
|
|
|
|
``metric_id`` is the collected source table; ``sheet_id`` identifies a
|
|
filtered split (e.g. bgp_peer.vpnv4). Presets key off the source metric.
|
|
"""
|
|
mid = str(metric_id or "").strip()
|
|
sid = str(sheet_id or "").strip()
|
|
# Callers may pass only a split id like "bgp_peer.vpnv4"
|
|
if not mid and sid:
|
|
mid = sid.split(".", 1)[0]
|
|
if not sid:
|
|
sid = mid
|
|
look = mid.split(".", 1)[0] if "." in mid else mid
|
|
|
|
def _out(body: dict[str, Any]) -> dict[str, Any]:
|
|
body["metric_id"] = mid or look
|
|
if sid and sid != (mid or look):
|
|
body["sheet_id"] = sid
|
|
return body
|
|
|
|
if look == PORT_METRIC_ID:
|
|
up = [
|
|
{"type": "value", "field": "admin", "op": "in", "value": ["up"]},
|
|
{"type": "value", "field": "phy", "op": "in", "value": ["up"]},
|
|
]
|
|
return _out(
|
|
{
|
|
"status_fields": list(PORT_STATUS_FIELDS),
|
|
"down_values": ["down"],
|
|
"up_values": ["up"],
|
|
"success": [
|
|
_success_stateful(
|
|
old_down_groups=[
|
|
[
|
|
{"type": "value", "field": "admin", "op": "in", "value": ["down"]},
|
|
{"type": "value", "field": "phy", "op": "in", "value": ["down"]},
|
|
]
|
|
],
|
|
new_up_conds=up,
|
|
)
|
|
],
|
|
"anomaly": _default_anomaly_for_state("admin", ["down"]),
|
|
}
|
|
)
|
|
if look == "bgp_peer":
|
|
up = [{"type": "value", "field": "state", "op": "eq", "value": "established"}]
|
|
return _out(
|
|
{
|
|
"status_fields": ["state"],
|
|
"down_values": ["idle", "active", "connect", "down"],
|
|
"up_values": ["established"],
|
|
"success": [
|
|
_success_stateful(
|
|
old_down_groups=[
|
|
[
|
|
{
|
|
"type": "value",
|
|
"field": "state",
|
|
"op": "in",
|
|
"value": ["idle", "active", "connect", "down"],
|
|
}
|
|
]
|
|
],
|
|
new_up_conds=up,
|
|
)
|
|
],
|
|
"anomaly": _default_anomaly_for_state(
|
|
"state", ["idle", "active", "connect", "down"]
|
|
),
|
|
}
|
|
)
|
|
if look in ("arp", "nd6_cache", "lldp_neighbor"):
|
|
return _out(
|
|
{
|
|
"status_fields": [],
|
|
"down_values": [],
|
|
"up_values": [],
|
|
"success": [_success_presence_migrate()],
|
|
"anomaly": _default_anomaly_presence_only(),
|
|
}
|
|
)
|
|
if "isis" in look or "ospf" in look or "adjacency" in look:
|
|
up = [
|
|
{
|
|
"type": "value",
|
|
"field": "state",
|
|
"op": "in",
|
|
"value": ["up", "full", "2way"],
|
|
}
|
|
]
|
|
return _out(
|
|
{
|
|
"status_fields": ["state"],
|
|
"down_values": ["down", "init", "idle"],
|
|
"up_values": ["up", "full", "2way"],
|
|
"success": [
|
|
_success_stateful(
|
|
old_down_groups=[
|
|
[
|
|
{
|
|
"type": "value",
|
|
"field": "state",
|
|
"op": "in",
|
|
"value": ["down", "init", "idle"],
|
|
}
|
|
]
|
|
],
|
|
new_up_conds=up,
|
|
)
|
|
],
|
|
"anomaly": _default_anomaly_for_state("state", ["down", "init", "idle"]),
|
|
}
|
|
)
|
|
if "route" in look or "vrf" in look:
|
|
return _out(
|
|
{
|
|
"status_fields": [],
|
|
"down_values": [],
|
|
"up_values": [],
|
|
"success": [_success_presence_migrate()],
|
|
"anomaly": _default_anomaly_presence_only(),
|
|
}
|
|
)
|
|
return _out(
|
|
{
|
|
"status_fields": [],
|
|
"down_values": [],
|
|
"up_values": [],
|
|
"success": [_success_presence_migrate()],
|
|
"anomaly": _default_anomaly_presence_only(),
|
|
}
|
|
)
|
|
|
|
|
|
DEFAULT_MONITOR_TEMPLATE_NAME = "默认割接监控"
|
|
|
|
|
|
def ensure_default_monitor_templates(db: Session) -> None:
|
|
"""Seed once when monitor-template table is empty — one template on the full status compare."""
|
|
if db.query(BizMonitorTemplate.id).limit(1).first():
|
|
return
|
|
cmp_svc.ensure_default_templates(db)
|
|
zte = (
|
|
db.query(BizCompareTemplate)
|
|
.filter(BizCompareTemplate.name == "ZTE status default")
|
|
.one_or_none()
|
|
)
|
|
if not zte:
|
|
return
|
|
zte_sheets = cmp_svc.template_metrics(zte)
|
|
zte_overrides = [
|
|
preset_override_for_metric(
|
|
str(s.get("metric_id") or ""),
|
|
sheet_id=str(s.get("sheet_id") or s.get("metric_id") or ""),
|
|
)
|
|
for s in zte_sheets
|
|
if s.get("metric_id")
|
|
]
|
|
db.add(
|
|
BizMonitorTemplate(
|
|
id=uuid4().hex,
|
|
name=DEFAULT_MONITOR_TEMPLATE_NAME,
|
|
compare_template_id=zte.id,
|
|
collect_metric_ids_json=[],
|
|
defaults_json={"dual_mode": "migrate_pair", "out_of_expect": "strict"},
|
|
sheet_overrides_json=zte_overrides,
|
|
note="Default: full status dual-verdict (port/ARP/BGP/…)",
|
|
created_at=_utcnow(),
|
|
updated_at=_utcnow(),
|
|
)
|
|
)
|
|
db.commit()
|
|
|
|
|
|
def default_port_monitor_template_id(db: Session) -> str:
|
|
"""Ensure seeds exist and return the default monitor template id."""
|
|
ensure_default_monitor_templates(db)
|
|
for name in (
|
|
DEFAULT_MONITOR_TEMPLATE_NAME,
|
|
"ZTE 状态割接监控",
|
|
"端口割接监控",
|
|
):
|
|
row = (
|
|
db.query(BizMonitorTemplate)
|
|
.filter(BizMonitorTemplate.name == name)
|
|
.one_or_none()
|
|
)
|
|
if row:
|
|
return row.id
|
|
first = db.query(BizMonitorTemplate).order_by(BizMonitorTemplate.created_at.asc()).first()
|
|
return first.id if first else ""
|
|
|
|
|
|
def get_monitor_template_row(db: Session, template_id: str) -> BizMonitorTemplate | None:
|
|
tid = str(template_id or "").strip()
|
|
if not tid:
|
|
return None
|
|
return db.get(BizMonitorTemplate, tid)
|
|
|
|
|
|
def list_monitor_templates(db: Session) -> list[dict[str, Any]]:
|
|
ensure_default_monitor_templates(db)
|
|
names = _compare_name_map(db)
|
|
rows = db.query(BizMonitorTemplate).order_by(BizMonitorTemplate.name.asc()).all()
|
|
return [_out(r, names.get(r.compare_template_id or "", ""), db=db) for r in rows]
|
|
|
|
|
|
def get_monitor_template(db: Session, template_id: str) -> dict[str, Any]:
|
|
row = db.get(BizMonitorTemplate, template_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="monitor_template_not_found")
|
|
names = _compare_name_map(db)
|
|
return _out(row, names.get(row.compare_template_id or "", ""), db=db)
|
|
|
|
|
|
def create_monitor_template(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
|
name = str(body.get("name") or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="name_required")
|
|
compare_id = str(body.get("compare_template_id") or "").strip()
|
|
if compare_id and not db.get(BizCompareTemplate, compare_id):
|
|
raise HTTPException(status_code=404, detail="compare_template_not_found")
|
|
collect = body.get("collect_metric_ids")
|
|
if not isinstance(collect, list):
|
|
collect = []
|
|
defaults = body.get("defaults")
|
|
if not isinstance(defaults, dict):
|
|
defaults = {"dual_mode": "migrate_pair", "out_of_expect": "strict"}
|
|
overrides = body.get("sheet_overrides")
|
|
if not isinstance(overrides, list):
|
|
overrides = []
|
|
row = BizMonitorTemplate(
|
|
id=uuid4().hex,
|
|
name=name[:256],
|
|
compare_template_id=compare_id,
|
|
collect_metric_ids_json=[str(x) for x in collect if str(x).strip()],
|
|
defaults_json=defaults,
|
|
sheet_overrides_json=overrides,
|
|
note=str(body.get("note") or "")[:512],
|
|
created_at=_utcnow(),
|
|
updated_at=_utcnow(),
|
|
)
|
|
db.add(row)
|
|
db.commit()
|
|
db.refresh(row)
|
|
names = _compare_name_map(db)
|
|
return _out(row, names.get(row.compare_template_id or "", ""), db=db)
|
|
|
|
|
|
def update_monitor_template(db: Session, template_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
|
row = db.get(BizMonitorTemplate, template_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="monitor_template_not_found")
|
|
if "name" in body and body["name"] is not None:
|
|
name = str(body["name"] or "").strip()
|
|
if not name:
|
|
raise HTTPException(status_code=400, detail="name_required")
|
|
row.name = name[:256]
|
|
if "compare_template_id" in body and body["compare_template_id"] is not None:
|
|
compare_id = str(body["compare_template_id"] or "").strip()
|
|
if compare_id and not db.get(BizCompareTemplate, compare_id):
|
|
raise HTTPException(status_code=404, detail="compare_template_not_found")
|
|
row.compare_template_id = compare_id
|
|
if "collect_metric_ids" in body and body["collect_metric_ids"] is not None:
|
|
collect = body["collect_metric_ids"]
|
|
if not isinstance(collect, list):
|
|
raise HTTPException(status_code=400, detail="collect_metric_ids_invalid")
|
|
row.collect_metric_ids_json = [str(x) for x in collect if str(x).strip()]
|
|
if "defaults" in body and body["defaults"] is not None:
|
|
if not isinstance(body["defaults"], dict):
|
|
raise HTTPException(status_code=400, detail="defaults_invalid")
|
|
row.defaults_json = body["defaults"]
|
|
if "sheet_overrides" in body and body["sheet_overrides"] is not None:
|
|
if not isinstance(body["sheet_overrides"], list):
|
|
raise HTTPException(status_code=400, detail="sheet_overrides_invalid")
|
|
row.sheet_overrides_json = body["sheet_overrides"]
|
|
if "note" in body and body["note"] is not None:
|
|
row.note = str(body["note"] or "")[:512]
|
|
row.updated_at = _utcnow()
|
|
db.commit()
|
|
db.refresh(row)
|
|
names = _compare_name_map(db)
|
|
return _out(row, names.get(row.compare_template_id or "", ""), db=db)
|
|
|
|
|
|
def delete_monitor_template(db: Session, template_id: str) -> None:
|
|
row = db.get(BizMonitorTemplate, template_id)
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="monitor_template_not_found")
|
|
db.delete(row)
|
|
db.commit()
|