mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10:46 +08:00
Decouple cutover HF from portrait and tighten ops UI.
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>
This commit is contained in:
parent
a65c9fc420
commit
285c19e5a3
67 changed files with 9510 additions and 723 deletions
|
|
@ -796,11 +796,21 @@ def _key_in_port_map(
|
||||||
iface_fields: list[str],
|
iface_fields: list[str],
|
||||||
port_map: dict[str, str],
|
port_map: dict[str, str],
|
||||||
) -> bool:
|
) -> bool:
|
||||||
"""True when every interface segment of the key is an old-side map entry."""
|
"""True when every interface segment of the key is covered by the map.
|
||||||
|
|
||||||
|
Covered = exact map key, or parent (before last ``.``) is a map key.
|
||||||
|
"""
|
||||||
vals = _iface_values(key_str, key_fields=key_fields, iface_fields=iface_fields)
|
vals = _iface_values(key_str, key_fields=key_fields, iface_fields=iface_fields)
|
||||||
if not vals:
|
if not vals:
|
||||||
return False
|
return False
|
||||||
return all(v in port_map for v in vals)
|
for v in vals:
|
||||||
|
if v in port_map:
|
||||||
|
continue
|
||||||
|
parent = v.rsplit(".", 1)[0] if "." in v else ""
|
||||||
|
if parent and parent in port_map:
|
||||||
|
continue
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
def _port_identity(key_fields: list[str], iface_fields: list[str]) -> bool:
|
def _port_identity(key_fields: list[str], iface_fields: list[str]) -> bool:
|
||||||
|
|
@ -832,24 +842,28 @@ def _remap_key_str(
|
||||||
port_map: dict[str, str],
|
port_map: dict[str, str],
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Map old-side key_str → new-side using iface segments (not whole-string only)."""
|
"""Map old-side key_str → new-side using iface segments (not whole-string only)."""
|
||||||
|
from ..biz_state.iface_normalize import resolve_mapped_iface
|
||||||
|
|
||||||
if not key_str:
|
if not key_str:
|
||||||
return key_str
|
return key_str
|
||||||
if not port_map:
|
if not port_map:
|
||||||
return key_str
|
return key_str
|
||||||
if key_str in port_map and (not key_fields or len(key_fields) == 1):
|
if (not key_fields or len(key_fields) == 1) and (
|
||||||
return port_map[key_str]
|
key_str in port_map or ("." in key_str and key_str.rsplit(".", 1)[0] in port_map)
|
||||||
|
):
|
||||||
|
return resolve_mapped_iface(key_str, port_map)
|
||||||
parts = str(key_str).split("|")
|
parts = str(key_str).split("|")
|
||||||
if key_fields and len(parts) == len(key_fields):
|
if key_fields and len(parts) == len(key_fields):
|
||||||
iface_set = {str(f) for f in iface_fields}
|
iface_set = {str(f) for f in iface_fields}
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for i, f in enumerate(key_fields):
|
for i, f in enumerate(key_fields):
|
||||||
v = parts[i]
|
v = parts[i]
|
||||||
if f in iface_set and v in port_map:
|
if f in iface_set:
|
||||||
out.append(port_map[v])
|
out.append(resolve_mapped_iface(v, port_map))
|
||||||
else:
|
else:
|
||||||
out.append(v)
|
out.append(v)
|
||||||
return "|".join(out)
|
return "|".join(out)
|
||||||
return port_map.get(key_str, key_str)
|
return resolve_mapped_iface(key_str, port_map)
|
||||||
|
|
||||||
|
|
||||||
def _reverse_remap_key_str(
|
def _reverse_remap_key_str(
|
||||||
|
|
@ -860,24 +874,28 @@ def _reverse_remap_key_str(
|
||||||
rev_map: dict[str, str],
|
rev_map: dict[str, str],
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Map new-side key_str → old-side canon identity."""
|
"""Map new-side key_str → old-side canon identity."""
|
||||||
|
from ..biz_state.iface_normalize import resolve_mapped_iface
|
||||||
|
|
||||||
if not key_str:
|
if not key_str:
|
||||||
return key_str
|
return key_str
|
||||||
if not rev_map:
|
if not rev_map:
|
||||||
return key_str
|
return key_str
|
||||||
if key_str in rev_map and (not key_fields or len(key_fields) == 1):
|
if (not key_fields or len(key_fields) == 1) and (
|
||||||
return rev_map[key_str]
|
key_str in rev_map or ("." in key_str and key_str.rsplit(".", 1)[0] in rev_map)
|
||||||
|
):
|
||||||
|
return resolve_mapped_iface(key_str, rev_map)
|
||||||
parts = str(key_str).split("|")
|
parts = str(key_str).split("|")
|
||||||
if key_fields and len(parts) == len(key_fields):
|
if key_fields and len(parts) == len(key_fields):
|
||||||
iface_set = {str(f) for f in iface_fields}
|
iface_set = {str(f) for f in iface_fields}
|
||||||
out: list[str] = []
|
out: list[str] = []
|
||||||
for i, f in enumerate(key_fields):
|
for i, f in enumerate(key_fields):
|
||||||
v = parts[i]
|
v = parts[i]
|
||||||
if f in iface_set and v in rev_map:
|
if f in iface_set:
|
||||||
out.append(rev_map[v])
|
out.append(resolve_mapped_iface(v, rev_map))
|
||||||
else:
|
else:
|
||||||
out.append(v)
|
out.append(v)
|
||||||
return "|".join(out)
|
return "|".join(out)
|
||||||
return rev_map.get(key_str, key_str)
|
return resolve_mapped_iface(key_str, rev_map)
|
||||||
|
|
||||||
|
|
||||||
def _current_row(diff: dict[str, Any] | None) -> dict[str, Any]:
|
def _current_row(diff: dict[str, Any] | None) -> dict[str, Any]:
|
||||||
|
|
@ -938,8 +956,14 @@ def evaluate_metric_dual(
|
||||||
sheet_override: dict[str, Any] | None = None,
|
sheet_override: dict[str, Any] | None = None,
|
||||||
out_of_expect: str = "strict",
|
out_of_expect: str = "strict",
|
||||||
sheet_id: str = "",
|
sheet_id: str = "",
|
||||||
|
iface_normalize_rules: list[dict[str, str]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Run old vs old-baseline, new vs new-baseline (or mapped old baseline), dual merge."""
|
"""Run old vs old-baseline, new vs new-baseline (or mapped old baseline), dual merge."""
|
||||||
|
from ..biz_state.iface_normalize import (
|
||||||
|
apply_iface_normalize_rows,
|
||||||
|
normalize_iface_rules,
|
||||||
|
)
|
||||||
|
|
||||||
sid = str(sheet_id or "").strip() or str(metric_id or "").strip()
|
sid = str(sheet_id or "").strip() or str(metric_id or "").strip()
|
||||||
expect_keys = expect_keys_for_metric(
|
expect_keys = expect_keys_for_metric(
|
||||||
expect, metric_id=metric_id, iface_fields=iface_fields, sheet_id=sid
|
expect, metric_id=metric_id, iface_fields=iface_fields, sheet_id=sid
|
||||||
|
|
@ -962,6 +986,21 @@ def evaluate_metric_dual(
|
||||||
success_patterns = list(ov.get("success") or []) if isinstance(ov.get("success"), list) else []
|
success_patterns = list(ov.get("success") or []) if isinstance(ov.get("success"), list) else []
|
||||||
anomaly_patterns = list(ov.get("anomaly") or []) if isinstance(ov.get("anomaly"), list) else []
|
anomaly_patterns = list(ov.get("anomaly") or []) if isinstance(ov.get("anomaly"), list) else []
|
||||||
|
|
||||||
|
norm_rules = normalize_iface_rules(iface_normalize_rules)
|
||||||
|
old_baseline_rows = apply_iface_normalize_rows(
|
||||||
|
old_baseline_rows, iface_fields=iface_fields, rules=norm_rules
|
||||||
|
)
|
||||||
|
old_current_rows = apply_iface_normalize_rows(
|
||||||
|
old_current_rows, iface_fields=iface_fields, rules=norm_rules
|
||||||
|
)
|
||||||
|
new_current_rows = apply_iface_normalize_rows(
|
||||||
|
new_current_rows, iface_fields=iface_fields, rules=norm_rules
|
||||||
|
)
|
||||||
|
if new_baseline_rows is not None:
|
||||||
|
new_baseline_rows = apply_iface_normalize_rows(
|
||||||
|
new_baseline_rows, iface_fields=iface_fields, rules=norm_rules
|
||||||
|
)
|
||||||
|
|
||||||
old_cmp = compare_rows(
|
old_cmp = compare_rows(
|
||||||
before_rows=old_baseline_rows,
|
before_rows=old_baseline_rows,
|
||||||
after_rows=old_current_rows,
|
after_rows=old_current_rows,
|
||||||
|
|
@ -970,6 +1009,7 @@ def evaluate_metric_dual(
|
||||||
compare_fields=compare_fields,
|
compare_fields=compare_fields,
|
||||||
port_map=None,
|
port_map=None,
|
||||||
field_rules=field_rules,
|
field_rules=field_rules,
|
||||||
|
iface_normalize_rules=None, # already applied
|
||||||
)
|
)
|
||||||
old_idx = build_diff_index_from_compare(old_cmp)
|
old_idx = build_diff_index_from_compare(old_cmp)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ from sqlalchemy.orm import Session
|
||||||
from ..models import BizCompareTemplate, BizMonitorTemplate
|
from ..models import BizCompareTemplate, BizMonitorTemplate
|
||||||
from ..timeutil import utcnow_naive
|
from ..timeutil import utcnow_naive
|
||||||
from ..biz_state import compare_service as cmp_svc
|
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
|
from .evaluate import PORT_METRIC_ID, PORT_STATUS_FIELDS
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -18,13 +19,27 @@ def _utcnow():
|
||||||
return utcnow_naive()
|
return utcnow_naive()
|
||||||
|
|
||||||
|
|
||||||
def _out(row: BizMonitorTemplate, compare_name: str = "") -> dict[str, Any]:
|
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 {
|
return {
|
||||||
"id": row.id,
|
"id": row.id,
|
||||||
"name": row.name,
|
"name": row.name,
|
||||||
"compare_template_id": row.compare_template_id or "",
|
"compare_template_id": row.compare_template_id or "",
|
||||||
"compare_template_name": compare_name,
|
"compare_template_name": compare_name,
|
||||||
"collect_metric_ids": list(row.collect_metric_ids_json or []),
|
"collect_metric_ids": collect,
|
||||||
|
"collect_metric_ids_effective": effective,
|
||||||
"defaults": dict(row.defaults_json or {}),
|
"defaults": dict(row.defaults_json or {}),
|
||||||
"sheet_overrides": list(row.sheet_overrides_json or []),
|
"sheet_overrides": list(row.sheet_overrides_json or []),
|
||||||
"note": row.note or "",
|
"note": row.note or "",
|
||||||
|
|
@ -52,6 +67,11 @@ def ensure_port_compare_template(db: Session) -> BizCompareTemplate:
|
||||||
"field_rules": [],
|
"field_rules": [],
|
||||||
}
|
}
|
||||||
if row:
|
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
|
return row
|
||||||
row = BizCompareTemplate(
|
row = BizCompareTemplate(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
|
|
@ -61,6 +81,7 @@ def ensure_port_compare_template(db: Session) -> BizCompareTemplate:
|
||||||
updated_at=_utcnow(),
|
updated_at=_utcnow(),
|
||||||
)
|
)
|
||||||
cmp_svc._apply_sheets_to_row(row, [sheet]) # noqa: SLF001 — shared normalizer
|
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.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
|
|
@ -262,31 +283,21 @@ def preset_override_for_metric(metric_id: str, *, sheet_id: str = "") -> dict[st
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
DEFAULT_MONITOR_TEMPLATE_NAME = "默认割接监控"
|
||||||
|
|
||||||
|
|
||||||
def ensure_default_monitor_templates(db: Session) -> None:
|
def ensure_default_monitor_templates(db: Session) -> None:
|
||||||
"""Seed once when monitor-template table is empty."""
|
"""Seed once when monitor-template table is empty — one template on the full status compare."""
|
||||||
if db.query(BizMonitorTemplate.id).limit(1).first():
|
if db.query(BizMonitorTemplate.id).limit(1).first():
|
||||||
return
|
return
|
||||||
cmp_svc.ensure_default_templates(db)
|
cmp_svc.ensure_default_templates(db)
|
||||||
port_tpl = ensure_port_compare_template(db)
|
|
||||||
zte = (
|
zte = (
|
||||||
db.query(BizCompareTemplate)
|
db.query(BizCompareTemplate)
|
||||||
.filter(BizCompareTemplate.name == "ZTE status default")
|
.filter(BizCompareTemplate.name == "ZTE status default")
|
||||||
.one_or_none()
|
.one_or_none()
|
||||||
)
|
)
|
||||||
seeds = [
|
if not zte:
|
||||||
BizMonitorTemplate(
|
return
|
||||||
id=uuid4().hex,
|
|
||||||
name="端口割接监控",
|
|
||||||
compare_template_id=port_tpl.id,
|
|
||||||
collect_metric_ids_json=[PORT_METRIC_ID],
|
|
||||||
defaults_json={"dual_mode": "migrate_pair", "out_of_expect": "strict"},
|
|
||||||
sheet_overrides_json=[preset_override_for_metric(PORT_METRIC_ID)],
|
|
||||||
note="Default: port status dual-verdict with up/down semantics",
|
|
||||||
created_at=_utcnow(),
|
|
||||||
updated_at=_utcnow(),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
if zte:
|
|
||||||
zte_sheets = cmp_svc.template_metrics(zte)
|
zte_sheets = cmp_svc.template_metrics(zte)
|
||||||
zte_overrides = [
|
zte_overrides = [
|
||||||
preset_override_for_metric(
|
preset_override_for_metric(
|
||||||
|
|
@ -296,30 +307,33 @@ def ensure_default_monitor_templates(db: Session) -> None:
|
||||||
for s in zte_sheets
|
for s in zte_sheets
|
||||||
if s.get("metric_id")
|
if s.get("metric_id")
|
||||||
]
|
]
|
||||||
seeds.append(
|
db.add(
|
||||||
BizMonitorTemplate(
|
BizMonitorTemplate(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
name="ZTE 状态割接监控",
|
name=DEFAULT_MONITOR_TEMPLATE_NAME,
|
||||||
compare_template_id=zte.id,
|
compare_template_id=zte.id,
|
||||||
collect_metric_ids_json=[],
|
collect_metric_ids_json=[],
|
||||||
defaults_json={"dual_mode": "migrate_pair", "out_of_expect": "strict"},
|
defaults_json={"dual_mode": "migrate_pair", "out_of_expect": "strict"},
|
||||||
sheet_overrides_json=zte_overrides,
|
sheet_overrides_json=zte_overrides,
|
||||||
note="ZTE multi-sheet: port/ARP/BGP/… dual presets",
|
note="Default: full status dual-verdict (port/ARP/BGP/…)",
|
||||||
created_at=_utcnow(),
|
created_at=_utcnow(),
|
||||||
updated_at=_utcnow(),
|
updated_at=_utcnow(),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
for s in seeds:
|
|
||||||
db.add(s)
|
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
def default_port_monitor_template_id(db: Session) -> str:
|
def default_port_monitor_template_id(db: Session) -> str:
|
||||||
"""Ensure seeds exist and return id of 「端口割接监控」 (or first template)."""
|
"""Ensure seeds exist and return the default monitor template id."""
|
||||||
ensure_default_monitor_templates(db)
|
ensure_default_monitor_templates(db)
|
||||||
|
for name in (
|
||||||
|
DEFAULT_MONITOR_TEMPLATE_NAME,
|
||||||
|
"ZTE 状态割接监控",
|
||||||
|
"端口割接监控",
|
||||||
|
):
|
||||||
row = (
|
row = (
|
||||||
db.query(BizMonitorTemplate)
|
db.query(BizMonitorTemplate)
|
||||||
.filter(BizMonitorTemplate.name == "端口割接监控")
|
.filter(BizMonitorTemplate.name == name)
|
||||||
.one_or_none()
|
.one_or_none()
|
||||||
)
|
)
|
||||||
if row:
|
if row:
|
||||||
|
|
@ -339,7 +353,7 @@ def list_monitor_templates(db: Session) -> list[dict[str, Any]]:
|
||||||
ensure_default_monitor_templates(db)
|
ensure_default_monitor_templates(db)
|
||||||
names = _compare_name_map(db)
|
names = _compare_name_map(db)
|
||||||
rows = db.query(BizMonitorTemplate).order_by(BizMonitorTemplate.name.asc()).all()
|
rows = db.query(BizMonitorTemplate).order_by(BizMonitorTemplate.name.asc()).all()
|
||||||
return [_out(r, names.get(r.compare_template_id or "", "")) for r in rows]
|
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]:
|
def get_monitor_template(db: Session, template_id: str) -> dict[str, Any]:
|
||||||
|
|
@ -347,7 +361,7 @@ def get_monitor_template(db: Session, template_id: str) -> dict[str, Any]:
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="monitor_template_not_found")
|
raise HTTPException(status_code=404, detail="monitor_template_not_found")
|
||||||
names = _compare_name_map(db)
|
names = _compare_name_map(db)
|
||||||
return _out(row, names.get(row.compare_template_id or "", ""))
|
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]:
|
def create_monitor_template(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|
@ -381,7 +395,7 @@ def create_monitor_template(db: Session, body: dict[str, Any]) -> dict[str, Any]
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
names = _compare_name_map(db)
|
names = _compare_name_map(db)
|
||||||
return _out(row, names.get(row.compare_template_id or "", ""))
|
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]:
|
def update_monitor_template(db: Session, template_id: str, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
|
@ -417,7 +431,7 @@ def update_monitor_template(db: Session, template_id: str, body: dict[str, Any])
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
names = _compare_name_map(db)
|
names = _compare_name_map(db)
|
||||||
return _out(row, names.get(row.compare_template_id or "", ""))
|
return _out(row, names.get(row.compare_template_id or "", ""), db=db)
|
||||||
|
|
||||||
|
|
||||||
def delete_monitor_template(db: Session, template_id: str) -> None:
|
def delete_monitor_template(db: Session, template_id: str) -> None:
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -17,22 +17,45 @@ router = APIRouter(prefix="/v1/biz-migration", tags=["biz-migration"])
|
||||||
|
|
||||||
class ProjectIn(BaseModel):
|
class ProjectIn(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
old_task_id: str
|
old_task_id: str = ""
|
||||||
new_task_id: str
|
new_task_id: str = ""
|
||||||
|
old_hf_task_id: str = ""
|
||||||
|
new_hf_task_id: str = ""
|
||||||
old_baseline_batch_id: str = ""
|
old_baseline_batch_id: str = ""
|
||||||
new_baseline_batch_id: str = ""
|
new_baseline_batch_id: str = ""
|
||||||
mapping_id: str = ""
|
mapping_id: str = ""
|
||||||
|
mapping_name: str = ""
|
||||||
|
mapping_rows: list[dict[str, Any]] = Field(default_factory=list)
|
||||||
monitor_template_id: str = ""
|
monitor_template_id: str = ""
|
||||||
status: str = "draft"
|
collect_metric_ids: list[str] = Field(default_factory=list)
|
||||||
|
metric_interval_sec: dict[str, int] = Field(default_factory=dict)
|
||||||
|
hf_interval_sec: int = 60
|
||||||
|
hf_start_at: str | None = None
|
||||||
|
hf_end_at: str | None = None
|
||||||
|
create_hf: bool = True
|
||||||
|
collect_now: bool = False
|
||||||
|
retention_days: int = 7
|
||||||
|
old_ne: dict[str, Any] | None = None
|
||||||
|
new_ne: dict[str, Any] | None = None
|
||||||
|
status: str = "active"
|
||||||
note: str = ""
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
class ProjectPatchIn(BaseModel):
|
class ProjectPatchIn(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
|
old_task_id: str | None = None
|
||||||
|
new_task_id: str | None = None
|
||||||
|
old_hf_task_id: str | None = None
|
||||||
|
new_hf_task_id: str | None = None
|
||||||
old_baseline_batch_id: str | None = None
|
old_baseline_batch_id: str | None = None
|
||||||
new_baseline_batch_id: str | None = None
|
new_baseline_batch_id: str | None = None
|
||||||
mapping_id: str | None = None
|
mapping_id: str | None = None
|
||||||
monitor_template_id: str | None = None
|
monitor_template_id: str | None = None
|
||||||
|
collect_metric_ids: list[str] | None = None
|
||||||
|
metric_interval_sec: dict[str, int] | None = None
|
||||||
|
hf_interval_sec: int | None = None
|
||||||
|
hf_start_at: str | None = None
|
||||||
|
hf_end_at: str | None = None
|
||||||
status: str | None = None
|
status: str | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
|
||||||
|
|
@ -79,6 +102,19 @@ def api_list_projects(db: Session = Depends(get_db)):
|
||||||
return {"items": svc.list_projects(db)}
|
return {"items": svc.list_projects(db)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/ne-portrait")
|
||||||
|
def api_ne_portrait(
|
||||||
|
source: str = "managed",
|
||||||
|
ne_id: str = "",
|
||||||
|
limit: int = 30,
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""Portrait biz_state task + recent batches for a NE (baseline picker)."""
|
||||||
|
if not str(ne_id or "").strip():
|
||||||
|
return {"source": source, "ne_id": "", "task": None, "batches": [], "hint": "ne_id_required"}
|
||||||
|
return svc.list_ne_portrait_options(db, source=source, ne_id=ne_id, limit=limit)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/projects")
|
@router.post("/projects")
|
||||||
def api_create_project(body: ProjectIn, db: Session = Depends(get_db)):
|
def api_create_project(body: ProjectIn, db: Session = Depends(get_db)):
|
||||||
return svc.create_project(db, body.model_dump())
|
return svc.create_project(db, body.model_dump())
|
||||||
|
|
@ -112,9 +148,14 @@ def api_baseline_expect(project_id: str, db: Session = Depends(get_db)):
|
||||||
|
|
||||||
|
|
||||||
class EnsureHighfreqIn(BaseModel):
|
class EnsureHighfreqIn(BaseModel):
|
||||||
interval_sec: int = 60
|
interval_sec: int | None = None
|
||||||
retention_days: int = 7
|
retention_days: int = 7
|
||||||
collect_now: bool = True
|
collect_now: bool = True
|
||||||
|
old_ne: dict[str, Any] | None = None
|
||||||
|
new_ne: dict[str, Any] | None = None
|
||||||
|
collect_metric_ids: list[str] | None = None
|
||||||
|
hf_start_at: str | None = None
|
||||||
|
hf_end_at: str | None = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/projects/{project_id}/ensure-port-highfreq")
|
@router.post("/projects/{project_id}/ensure-port-highfreq")
|
||||||
|
|
@ -124,14 +165,28 @@ def api_ensure_port_highfreq(
|
||||||
body: EnsureHighfreqIn | None = None,
|
body: EnsureHighfreqIn | None = None,
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Create/bind HF biz_state tasks from monitor template collect_metric_ids."""
|
"""Create/bind HF biz_state tasks into hf slots (portrait slots untouched)."""
|
||||||
payload = body.model_dump() if body else {}
|
payload = body.model_dump(exclude_unset=True) if body else {}
|
||||||
|
# Persist schedule / collect override before ensure
|
||||||
|
patch: dict[str, Any] = {}
|
||||||
|
if "collect_metric_ids" in payload and payload["collect_metric_ids"] is not None:
|
||||||
|
patch["collect_metric_ids"] = payload["collect_metric_ids"]
|
||||||
|
if "hf_start_at" in payload:
|
||||||
|
patch["hf_start_at"] = payload.get("hf_start_at")
|
||||||
|
if "hf_end_at" in payload:
|
||||||
|
patch["hf_end_at"] = payload.get("hf_end_at")
|
||||||
|
if "interval_sec" in payload and payload["interval_sec"] is not None:
|
||||||
|
patch["hf_interval_sec"] = payload["interval_sec"]
|
||||||
|
if patch:
|
||||||
|
svc.patch_project(db, project_id, patch)
|
||||||
return svc.ensure_highfreq(
|
return svc.ensure_highfreq(
|
||||||
db,
|
db,
|
||||||
project_id,
|
project_id,
|
||||||
interval_sec=int(payload.get("interval_sec") or 60),
|
interval_sec=payload.get("interval_sec"),
|
||||||
retention_days=int(payload.get("retention_days") or 7),
|
retention_days=int(payload.get("retention_days") or 7),
|
||||||
collect_now=bool(payload.get("collect_now", True)),
|
collect_now=bool(payload.get("collect_now", True)),
|
||||||
|
old_ne=payload.get("old_ne"),
|
||||||
|
new_ne=payload.get("new_ne"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -158,10 +158,27 @@ def _persist_vrf_route_summary(
|
||||||
_GENERIC_METRICS = {
|
_GENERIC_METRICS = {
|
||||||
"isis_adjacency",
|
"isis_adjacency",
|
||||||
"interface_brief",
|
"interface_brief",
|
||||||
|
"interface_detail",
|
||||||
"arp",
|
"arp",
|
||||||
"if_intf",
|
"if_intf",
|
||||||
"nd6_cache",
|
"nd6_cache",
|
||||||
"bgp_peer",
|
"bgp_peer",
|
||||||
|
"ospf_neighbor",
|
||||||
|
"vrrp",
|
||||||
|
"optical_brief",
|
||||||
|
"bgp_route",
|
||||||
|
"ip_route",
|
||||||
|
"ipv6_route",
|
||||||
|
"l2vpn_pw",
|
||||||
|
"l2vpn_mac",
|
||||||
|
"evpn_mac",
|
||||||
|
"config_vrf",
|
||||||
|
"config_interface",
|
||||||
|
"config_bgp_peer",
|
||||||
|
"config_l2vpn_pw",
|
||||||
|
"config_static_route",
|
||||||
|
"config_ospf",
|
||||||
|
"config_isis",
|
||||||
}
|
}
|
||||||
_METRIC_CHUNK = 2000
|
_METRIC_CHUNK = 2000
|
||||||
|
|
||||||
|
|
@ -500,7 +517,7 @@ def _run_collect_session(
|
||||||
aux_results: dict[str, Any] = {}
|
aux_results: dict[str, Any] = {}
|
||||||
for aux in list(hit.profile.aux_commands or []):
|
for aux in list(hit.profile.aux_commands or []):
|
||||||
try:
|
try:
|
||||||
ra = resolve_aux_command(aux)
|
ra = resolve_aux_command(aux, params=merged)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
aux_row = BizStateBatchCommand(
|
aux_row = BizStateBatchCommand(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
|
|
@ -534,6 +551,7 @@ def _run_collect_session(
|
||||||
ra.command,
|
ra.command,
|
||||||
parser_id=ra.parser_id,
|
parser_id=ra.parser_id,
|
||||||
textfsm_command=ra.textfsm_command,
|
textfsm_command=ra.textfsm_command,
|
||||||
|
params=merged,
|
||||||
cmd_row_id=aux_row.id,
|
cmd_row_id=aux_row.id,
|
||||||
)
|
)
|
||||||
aux_results[ra.key] = entry
|
aux_results[ra.key] = entry
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||||
|
|
||||||
from dataclasses import dataclass, field
|
from dataclasses import dataclass, field
|
||||||
from typing import Any, Callable
|
from typing import Any, Callable
|
||||||
|
import re
|
||||||
|
|
||||||
from .command_match import normalize_command
|
from .command_match import normalize_command
|
||||||
from .enrich import EnrichJoin, apply_enrich_joins
|
from .enrich import EnrichJoin, apply_enrich_joins
|
||||||
|
|
@ -34,8 +35,11 @@ class ResolvedAux:
|
||||||
profile: ParseProfile
|
profile: ParseProfile
|
||||||
|
|
||||||
|
|
||||||
def resolve_aux_command(aux: AuxCommand) -> ResolvedAux:
|
def resolve_aux_command(
|
||||||
"""Resolve aux from ``profile_id`` (single source of truth)."""
|
aux: AuxCommand,
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> ResolvedAux:
|
||||||
|
"""Resolve aux from ``profile_id``; render ``<placeholders>`` from primary params."""
|
||||||
key = str(aux.key or "").strip()
|
key = str(aux.key or "").strip()
|
||||||
pid = str(aux.profile_id or "").strip()
|
pid = str(aux.profile_id or "").strip()
|
||||||
if not key or not pid:
|
if not key or not pid:
|
||||||
|
|
@ -43,10 +47,22 @@ def resolve_aux_command(aux: AuxCommand) -> ResolvedAux:
|
||||||
prof = get_profile(pid)
|
prof = get_profile(pid)
|
||||||
if not prof:
|
if not prof:
|
||||||
raise ValueError(f"aux profile not found: {pid}")
|
raise ValueError(f"aux profile not found: {pid}")
|
||||||
cmd = normalize_command(prof.command_template)
|
tmpl = str(prof.command_template or "")
|
||||||
|
rendered = tmpl
|
||||||
|
for name, val in dict(params or {}).items():
|
||||||
|
n = str(name or "").strip()
|
||||||
|
if not n:
|
||||||
|
continue
|
||||||
|
rendered = rendered.replace(f"<{n}>", str(val or "").strip())
|
||||||
|
cmd = normalize_command(rendered)
|
||||||
if not cmd:
|
if not cmd:
|
||||||
raise ValueError(f"aux profile {pid} has empty command_template")
|
raise ValueError(f"aux profile {pid} has empty command_template")
|
||||||
|
if re.search(r"<[^>]+>", cmd):
|
||||||
|
raise ValueError(f"aux profile {pid} unresolved placeholders: {cmd}")
|
||||||
textfsm = str(prof.textfsm_command or cmd).strip()
|
textfsm = str(prof.textfsm_command or cmd).strip()
|
||||||
|
# Prefer concrete rendered command for FSM when aux had placeholders
|
||||||
|
if params and any(f"<{k}>" in tmpl for k in (params or {})):
|
||||||
|
textfsm = cmd
|
||||||
parser_id = str(prof.parser_id or "").strip()
|
parser_id = str(prof.parser_id or "").strip()
|
||||||
meta = get_parser_meta(parser_id) if parser_id else None
|
meta = get_parser_meta(parser_id) if parser_id else None
|
||||||
rule_keys = tuple((meta or {}).get("rule_keys") or ())
|
rule_keys = tuple((meta or {}).get("rule_keys") or ())
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,11 @@ from __future__ import annotations
|
||||||
from typing import Any, Mapping, Sequence
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
from .compare_rules import field_rule_map, values_equal, explain_diff
|
from .compare_rules import field_rule_map, values_equal, explain_diff
|
||||||
|
from .iface_normalize import (
|
||||||
|
apply_iface_normalize_rows,
|
||||||
|
normalize_iface_rules,
|
||||||
|
resolve_mapped_iface,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def apply_port_map(
|
def apply_port_map(
|
||||||
|
|
@ -13,11 +18,12 @@ def apply_port_map(
|
||||||
iface_fields: list[str],
|
iface_fields: list[str],
|
||||||
port_map: dict[str, str],
|
port_map: dict[str, str],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Rewrite iface columns on a row (exact map or parent.subif inheritance)."""
|
||||||
out = dict(row)
|
out = dict(row)
|
||||||
for f in iface_fields:
|
for f in iface_fields:
|
||||||
val = str(out.get(f) or "").strip()
|
val = str(out.get(f) or "").strip()
|
||||||
if val and val in port_map:
|
if val:
|
||||||
out[f] = port_map[val]
|
out[f] = resolve_mapped_iface(val, port_map)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -32,7 +38,11 @@ def mapping_stats(
|
||||||
iface_fields: list[str],
|
iface_fields: list[str],
|
||||||
port_map: dict[str, str],
|
port_map: dict[str, str],
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""hit/miss/unused for port mapping validation."""
|
"""hit/miss/unused for port mapping validation.
|
||||||
|
|
||||||
|
``hit_before``: map key appears as a normalized before iface, or as the
|
||||||
|
parent of a before subinterface (so main-port-only maps still validate).
|
||||||
|
"""
|
||||||
before_ifaces: set[str] = set()
|
before_ifaces: set[str] = set()
|
||||||
after_ifaces: set[str] = set()
|
after_ifaces: set[str] = set()
|
||||||
for f in iface_fields:
|
for f in iface_fields:
|
||||||
|
|
@ -45,13 +55,25 @@ def mapping_stats(
|
||||||
if v:
|
if v:
|
||||||
after_ifaces.add(v)
|
after_ifaces.add(v)
|
||||||
|
|
||||||
|
before_bases: set[str] = set()
|
||||||
|
for v in before_ifaces:
|
||||||
|
before_bases.add(v)
|
||||||
|
if "." in v:
|
||||||
|
before_bases.add(v.rsplit(".", 1)[0])
|
||||||
|
|
||||||
|
after_bases: set[str] = set()
|
||||||
|
for v in after_ifaces:
|
||||||
|
after_bases.add(v)
|
||||||
|
if "." in v:
|
||||||
|
after_bases.add(v.rsplit(".", 1)[0])
|
||||||
|
|
||||||
mapped_before = set(port_map.keys())
|
mapped_before = set(port_map.keys())
|
||||||
mapped_after = set(port_map.values())
|
mapped_after = set(port_map.values())
|
||||||
hit_before = sorted(mapped_before & before_ifaces)
|
hit_before = sorted(mapped_before & before_bases)
|
||||||
miss_before = sorted(mapped_before - before_ifaces)
|
miss_before = sorted(mapped_before - before_bases)
|
||||||
unused = sorted(mapped_before - before_ifaces) # same as miss for before presence
|
unused = list(miss_before)
|
||||||
hit_after = sorted(mapped_after & after_ifaces)
|
hit_after = sorted(mapped_after & after_bases)
|
||||||
miss_after = sorted(mapped_after - after_ifaces)
|
miss_after = sorted(mapped_after - after_bases)
|
||||||
return {
|
return {
|
||||||
"before_iface_count": len(before_ifaces),
|
"before_iface_count": len(before_ifaces),
|
||||||
"after_iface_count": len(after_ifaces),
|
"after_iface_count": len(after_ifaces),
|
||||||
|
|
@ -74,13 +96,18 @@ def compare_rows(
|
||||||
compare_fields: list[str],
|
compare_fields: list[str],
|
||||||
port_map: dict[str, str] | None = None,
|
port_map: dict[str, str] | None = None,
|
||||||
field_rules: Sequence[Mapping[str, Any]] | None = None,
|
field_rules: Sequence[Mapping[str, Any]] | None = None,
|
||||||
|
iface_normalize_rules: Sequence[Mapping[str, str]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return summary + diffs list.
|
"""Return summary + diffs list.
|
||||||
|
|
||||||
Diff kinds: added | removed | changed | unchanged
|
Diff kinds: added | removed | changed | unchanged
|
||||||
|
|
||||||
Empty ``port_map`` means ignore port/iface changes: matching keys exclude
|
Pipeline: iface normalize (both sides) → port map (before) → match.
|
||||||
``iface_fields`` (same-device batches compare without a rename map).
|
|
||||||
|
Empty ``port_map``: try to ignore port renames by dropping ``iface_fields``
|
||||||
|
from the match key (LLDP-style). If that would collapse distinct rows
|
||||||
|
(OSPF/VRRP/config where the same id appears on many interfaces), keep
|
||||||
|
iface columns so identity compare stays correct.
|
||||||
|
|
||||||
``field_rules`` drives normalize / numeric tolerance / per-field compare mode
|
``field_rules`` drives normalize / numeric tolerance / per-field compare mode
|
||||||
(template-driven; no metric-specific branches here).
|
(template-driven; no metric-specific branches here).
|
||||||
|
|
@ -89,24 +116,49 @@ def compare_rows(
|
||||||
raise ValueError("key_fields required")
|
raise ValueError("key_fields required")
|
||||||
pmap = dict(port_map or {})
|
pmap = dict(port_map or {})
|
||||||
rules = field_rule_map(field_rules)
|
rules = field_rule_map(field_rules)
|
||||||
iface_set = {str(f) for f in (iface_fields or []) if str(f).strip()}
|
norm_rules = normalize_iface_rules(iface_normalize_rules)
|
||||||
# No map → ignore port renames: drop iface columns from the match key.
|
iface_list = [str(f) for f in (iface_fields or []) if str(f).strip()]
|
||||||
|
iface_set = set(iface_list)
|
||||||
|
|
||||||
|
before_norm = apply_iface_normalize_rows(
|
||||||
|
before_rows, iface_fields=iface_list, rules=norm_rules
|
||||||
|
)
|
||||||
|
after_norm = apply_iface_normalize_rows(
|
||||||
|
after_rows, iface_fields=iface_list, rules=norm_rules
|
||||||
|
)
|
||||||
|
|
||||||
|
ignore_port_changes = False
|
||||||
|
# No map → optionally ignore port renames by dropping iface from match key.
|
||||||
if not pmap and iface_set:
|
if not pmap and iface_set:
|
||||||
match_keys = [k for k in key_fields if k not in iface_set]
|
candidate = [k for k in key_fields if k not in iface_set]
|
||||||
if not match_keys:
|
if not candidate:
|
||||||
|
match_keys = list(key_fields)
|
||||||
|
else:
|
||||||
|
before_c = [row_key(r, candidate) for r in before_norm]
|
||||||
|
after_c = [row_key(r, candidate) for r in after_norm]
|
||||||
|
if len(before_c) == len(set(before_c)) and len(after_c) == len(set(after_c)):
|
||||||
|
match_keys = candidate
|
||||||
|
ignore_port_changes = True
|
||||||
|
else:
|
||||||
|
# Remaining keys are not unique — iface is required for identity.
|
||||||
match_keys = list(key_fields)
|
match_keys = list(key_fields)
|
||||||
else:
|
else:
|
||||||
match_keys = list(key_fields)
|
match_keys = list(key_fields)
|
||||||
|
|
||||||
before_mapped: list[dict[str, Any]] = [
|
before_mapped: list[dict[str, Any]] = [
|
||||||
apply_port_map(r, iface_fields=iface_fields, port_map=pmap) for r in before_rows
|
apply_port_map(r, iface_fields=iface_list, port_map=pmap) for r in before_norm
|
||||||
]
|
]
|
||||||
|
|
||||||
after_index: dict[tuple[str, ...], dict[str, Any]] = {}
|
after_index: dict[tuple[str, ...], dict[str, Any]] = {}
|
||||||
for r in after_rows:
|
after_dup = 0
|
||||||
after_index[row_key(r, match_keys)] = r
|
for r in after_norm:
|
||||||
|
k = row_key(r, match_keys)
|
||||||
|
if k in after_index:
|
||||||
|
after_dup += 1
|
||||||
|
after_index[k] = r
|
||||||
|
|
||||||
before_keys: set[tuple[str, ...]] = set()
|
before_keys: set[tuple[str, ...]] = set()
|
||||||
|
before_dup = 0
|
||||||
diffs: list[dict[str, Any]] = []
|
diffs: list[dict[str, Any]] = []
|
||||||
added = removed = changed = unchanged = 0
|
added = removed = changed = unchanged = 0
|
||||||
|
|
||||||
|
|
@ -115,6 +167,8 @@ def compare_rows(
|
||||||
|
|
||||||
for orig, mapped in zip(before_rows, before_mapped):
|
for orig, mapped in zip(before_rows, before_mapped):
|
||||||
k = row_key(mapped, match_keys)
|
k = row_key(mapped, match_keys)
|
||||||
|
if k in before_keys:
|
||||||
|
before_dup += 1
|
||||||
before_keys.add(k)
|
before_keys.add(k)
|
||||||
after = after_index.get(k)
|
after = after_index.get(k)
|
||||||
if after is None:
|
if after is None:
|
||||||
|
|
@ -184,13 +238,13 @@ def compare_rows(
|
||||||
)
|
)
|
||||||
|
|
||||||
stats = mapping_stats(
|
stats = mapping_stats(
|
||||||
before_rows=before_rows,
|
before_rows=before_norm,
|
||||||
after_rows=after_rows,
|
after_rows=after_norm,
|
||||||
iface_fields=iface_fields,
|
iface_fields=iface_list,
|
||||||
port_map=pmap,
|
port_map=pmap,
|
||||||
)
|
)
|
||||||
if not pmap:
|
if not pmap:
|
||||||
stats = {**stats, "ok": True, "ignore_port_changes": True}
|
stats = {**stats, "ok": True, "ignore_port_changes": ignore_port_changes}
|
||||||
return {
|
return {
|
||||||
"summary": {
|
"summary": {
|
||||||
"before_count": len(before_rows),
|
"before_count": len(before_rows),
|
||||||
|
|
@ -200,6 +254,8 @@ def compare_rows(
|
||||||
"changed": changed,
|
"changed": changed,
|
||||||
"unchanged": unchanged,
|
"unchanged": unchanged,
|
||||||
"match_key_fields": match_keys,
|
"match_key_fields": match_keys,
|
||||||
|
"duplicate_keys_before": before_dup,
|
||||||
|
"duplicate_keys_after": after_dup,
|
||||||
},
|
},
|
||||||
"diffs": diffs,
|
"diffs": diffs,
|
||||||
"mapping_stats": stats,
|
"mapping_stats": stats,
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ from .compare_rules import (
|
||||||
effective_compare_fields,
|
effective_compare_fields,
|
||||||
effective_display_fields,
|
effective_display_fields,
|
||||||
)
|
)
|
||||||
|
from .iface_normalize import (
|
||||||
|
apply_iface_normalize_rows,
|
||||||
|
default_zte_iface_normalize_rules,
|
||||||
|
normalize_iface_rules,
|
||||||
|
)
|
||||||
from .profiles import metric_field_map
|
from .profiles import metric_field_map
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -397,6 +402,15 @@ def _default_sheet_for_metric(metric_id: str, *, compare_roles: tuple[str, ...]
|
||||||
# Context columns: show but not necessarily compare
|
# Context columns: show but not necessarily compare
|
||||||
ctx = [n for n in ("vrf", "entry_type", "age") if n not in keys and n not in compare]
|
ctx = [n for n in ("vrf", "entry_type", "age") if n not in keys and n not in compare]
|
||||||
extra["display_fields"] = list(keys) + list(compare) + ctx
|
extra["display_fields"] = list(keys) + list(compare) + ctx
|
||||||
|
elif metric_id in ("interface_detail", "optical_brief", "bgp_peer"):
|
||||||
|
# Counters (rates / optical power / pfx_rcd) stay visible but out of default compare.
|
||||||
|
counters = [f.name for f in fields if (not f.is_key) and f.role == "counter"]
|
||||||
|
meta = [
|
||||||
|
f.name
|
||||||
|
for f in fields
|
||||||
|
if (not f.is_key) and f.role == "meta" and f.name not in compare
|
||||||
|
]
|
||||||
|
extra["display_fields"] = list(keys) + list(compare) + counters + meta
|
||||||
return _sheet_def(
|
return _sheet_def(
|
||||||
metric_id=metric_id,
|
metric_id=metric_id,
|
||||||
key_fields=keys,
|
key_fields=keys,
|
||||||
|
|
@ -449,6 +463,20 @@ def _bgp_afi_sheets() -> list[dict[str, Any]]:
|
||||||
("bgp_peer.ipv6", "BGP IPv6", "ipv6"),
|
("bgp_peer.ipv6", "BGP IPv6", "ipv6"),
|
||||||
("bgp_peer.vpnv4", "BGP VPNv4", "vpnv4"),
|
("bgp_peer.vpnv4", "BGP VPNv4", "vpnv4"),
|
||||||
("bgp_peer.vpnv6", "BGP VPNv6", "vpnv6"),
|
("bgp_peer.vpnv6", "BGP VPNv6", "vpnv6"),
|
||||||
|
("bgp_peer.evpn", "BGP EVPN", "evpn"),
|
||||||
|
("bgp_peer.vpls", "BGP VPLS", "vpls"),
|
||||||
|
),
|
||||||
|
op="eq",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _vrrp_af_sheets() -> list[dict[str, Any]]:
|
||||||
|
return _sheets_split_by_field(
|
||||||
|
"vrrp",
|
||||||
|
"af",
|
||||||
|
(
|
||||||
|
("vrrp.ipv4", "VRRP IPv4", "ipv4"),
|
||||||
|
("vrrp.ipv6", "VRRP IPv6", "ipv6"),
|
||||||
),
|
),
|
||||||
op="eq",
|
op="eq",
|
||||||
)
|
)
|
||||||
|
|
@ -470,6 +498,7 @@ def _builtin_source_splits() -> dict[str, list[dict[str, Any]]]:
|
||||||
return {
|
return {
|
||||||
"bgp_peer": _bgp_afi_sheets(),
|
"bgp_peer": _bgp_afi_sheets(),
|
||||||
"isis_adjacency": _isis_af_sheets(),
|
"isis_adjacency": _isis_af_sheets(),
|
||||||
|
"vrrp": _vrrp_af_sheets(),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -477,9 +506,44 @@ def _default_zte_status_sheets() -> list[dict[str, Any]]:
|
||||||
return [
|
return [
|
||||||
*_isis_af_sheets(),
|
*_isis_af_sheets(),
|
||||||
_default_sheet_for_metric("interface_brief", compare_roles=("state",)),
|
_default_sheet_for_metric("interface_brief", compare_roles=("state",)),
|
||||||
|
_default_sheet_for_metric("interface_detail", compare_roles=("state",)),
|
||||||
_default_sheet_for_metric("arp", compare_roles=("state",)),
|
_default_sheet_for_metric("arp", compare_roles=("state",)),
|
||||||
_default_sheet_for_metric("nd6_cache", compare_roles=("state",)),
|
_default_sheet_for_metric("nd6_cache", compare_roles=("state",)),
|
||||||
|
_default_sheet_for_metric("ospf_neighbor", compare_roles=("state",)),
|
||||||
|
*_vrrp_af_sheets(),
|
||||||
|
_default_sheet_for_metric("optical_brief", compare_roles=("state",)),
|
||||||
*_bgp_afi_sheets(),
|
*_bgp_afi_sheets(),
|
||||||
|
_default_sheet_for_metric("l2vpn_pw", compare_roles=("state",)),
|
||||||
|
_default_lldp_sheet(),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def _default_zte_config_sheets() -> list[dict[str, Any]]:
|
||||||
|
"""Config-intent metrics for cutover / intent-vs-intent compare."""
|
||||||
|
return [
|
||||||
|
_default_sheet_for_metric("config_vrf", compare_roles=("state",)),
|
||||||
|
_default_sheet_for_metric("config_interface", compare_roles=("state",)),
|
||||||
|
_default_sheet_for_metric("config_bgp_peer", compare_roles=("state",)),
|
||||||
|
_default_sheet_for_metric("config_l2vpn_pw", compare_roles=("state",)),
|
||||||
|
*_sheets_split_by_field(
|
||||||
|
"config_static_route",
|
||||||
|
"af",
|
||||||
|
(
|
||||||
|
("config_static_route.ipv4", "Static IPv4", "ipv4"),
|
||||||
|
("config_static_route.ipv6", "Static IPv6", "ipv6"),
|
||||||
|
),
|
||||||
|
compare_roles=("state",),
|
||||||
|
),
|
||||||
|
*_sheets_split_by_field(
|
||||||
|
"config_ospf",
|
||||||
|
"af",
|
||||||
|
(
|
||||||
|
("config_ospf.ipv4", "OSPF IPv4", "ipv4"),
|
||||||
|
("config_ospf.ipv6", "OSPF IPv6", "ipv6"),
|
||||||
|
),
|
||||||
|
compare_roles=("state",),
|
||||||
|
),
|
||||||
|
_default_sheet_for_metric("config_isis", compare_roles=("state",)),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -622,6 +686,17 @@ def _parse_metrics_body(body: dict[str, Any]) -> list[dict[str, Any]]:
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def template_iface_normalize(t: BizCompareTemplate | None) -> list[dict[str, str]]:
|
||||||
|
"""Resolved iface type-alias rules for a compare template."""
|
||||||
|
if t is None:
|
||||||
|
return []
|
||||||
|
return normalize_iface_rules(getattr(t, "iface_normalize_json", None) or [])
|
||||||
|
|
||||||
|
|
||||||
|
def _set_template_iface_normalize(t: BizCompareTemplate, raw: Any) -> None:
|
||||||
|
t.iface_normalize_json = normalize_iface_rules(raw)
|
||||||
|
|
||||||
|
|
||||||
def _template_out(t: BizCompareTemplate) -> dict[str, Any]:
|
def _template_out(t: BizCompareTemplate) -> dict[str, Any]:
|
||||||
sheets = template_metrics(t)
|
sheets = template_metrics(t)
|
||||||
first = sheets[0] if sheets else None
|
first = sheets[0] if sheets else None
|
||||||
|
|
@ -636,6 +711,7 @@ def _template_out(t: BizCompareTemplate) -> dict[str, Any]:
|
||||||
"iface_fields": list((first or {}).get("iface_fields") or t.iface_fields or []),
|
"iface_fields": list((first or {}).get("iface_fields") or t.iface_fields or []),
|
||||||
"compare_fields": list((first or {}).get("compare_fields") or t.compare_fields or []),
|
"compare_fields": list((first or {}).get("compare_fields") or t.compare_fields or []),
|
||||||
"ignore_fields": [],
|
"ignore_fields": [],
|
||||||
|
"iface_normalize_rules": template_iface_normalize(t),
|
||||||
"note": t.note,
|
"note": t.note,
|
||||||
"updated_at": t.updated_at.isoformat() + "Z" if t.updated_at else None,
|
"updated_at": t.updated_at.isoformat() + "Z" if t.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
@ -730,21 +806,37 @@ def ensure_default_zte_status_template(db: Session) -> BizCompareTemplate:
|
||||||
name = "ZTE status default"
|
name = "ZTE status default"
|
||||||
row = db.query(BizCompareTemplate).filter(BizCompareTemplate.name == name).one_or_none()
|
row = db.query(BizCompareTemplate).filter(BizCompareTemplate.name == name).one_or_none()
|
||||||
sheets = _default_zte_status_sheets()
|
sheets = _default_zte_status_sheets()
|
||||||
|
# Volatile counters must not stay in compare_fields on upgraded installs.
|
||||||
|
_STRIP_COMPARE: dict[str, frozenset[str]] = {
|
||||||
|
"interface_detail": frozenset({"input_bps", "output_bps", "in_util", "out_util"}),
|
||||||
|
"optical_brief": frozenset({"rx_power", "tx_power"}),
|
||||||
|
"bgp_peer": frozenset({"pfx_rcd"}),
|
||||||
|
}
|
||||||
if row:
|
if row:
|
||||||
existing = template_metrics(row)
|
existing = template_metrics(row)
|
||||||
want = {s["metric_id"] for s in sheets}
|
want = {s["metric_id"] for s in sheets}
|
||||||
have = {s["metric_id"] for s in existing}
|
have = {s["metric_id"] for s in existing}
|
||||||
changed = bool(want - have)
|
changed = bool(want - have)
|
||||||
# Migrate ARP sheet: inject template row_filters if missing (replaces code filter)
|
|
||||||
upgraded: list[dict[str, Any]] = []
|
upgraded: list[dict[str, Any]] = []
|
||||||
by_want = {s["metric_id"]: s for s in sheets}
|
by_want = {s["metric_id"]: s for s in sheets}
|
||||||
for s in existing:
|
for s in existing:
|
||||||
cur = dict(s)
|
cur = dict(s)
|
||||||
if cur.get("metric_id") == "arp" and not cur.get("row_filters"):
|
mid = str(cur.get("metric_id") or "")
|
||||||
|
if mid == "arp" and not cur.get("row_filters"):
|
||||||
cur["row_filters"] = list(by_want.get("arp", {}).get("row_filters") or arp_dynamic_row_filters())
|
cur["row_filters"] = list(by_want.get("arp", {}).get("row_filters") or arp_dynamic_row_filters())
|
||||||
if not cur.get("field_rules") and by_want.get("arp", {}).get("field_rules"):
|
if not cur.get("field_rules") and by_want.get("arp", {}).get("field_rules"):
|
||||||
cur["field_rules"] = list(by_want["arp"]["field_rules"])
|
cur["field_rules"] = list(by_want["arp"]["field_rules"])
|
||||||
changed = True
|
changed = True
|
||||||
|
strip = _STRIP_COMPARE.get(mid)
|
||||||
|
if strip:
|
||||||
|
old_cmp = list(cur.get("compare_fields") or [])
|
||||||
|
new_cmp = [f for f in old_cmp if f not in strip]
|
||||||
|
if new_cmp != old_cmp:
|
||||||
|
cur["compare_fields"] = new_cmp
|
||||||
|
want_disp = list((by_want.get(mid) or {}).get("display_fields") or [])
|
||||||
|
if want_disp:
|
||||||
|
cur["display_fields"] = want_disp
|
||||||
|
changed = True
|
||||||
upgraded.append(_normalize_sheet(cur) or cur)
|
upgraded.append(_normalize_sheet(cur) or cur)
|
||||||
if want - have:
|
if want - have:
|
||||||
for s in sheets:
|
for s in sheets:
|
||||||
|
|
@ -753,7 +845,12 @@ def ensure_default_zte_status_template(db: Session) -> BizCompareTemplate:
|
||||||
changed = True
|
changed = True
|
||||||
if changed:
|
if changed:
|
||||||
_apply_sheets_to_row(row, upgraded if upgraded else sheets)
|
_apply_sheets_to_row(row, upgraded if upgraded else sheets)
|
||||||
row.note = "Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP)"
|
row.note = "Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP/LLDP)"
|
||||||
|
row.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
if not template_iface_normalize(row):
|
||||||
|
_set_template_iface_normalize(row, default_zte_iface_normalize_rules())
|
||||||
row.updated_at = _utcnow()
|
row.updated_at = _utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
|
|
@ -761,11 +858,51 @@ def ensure_default_zte_status_template(db: Session) -> BizCompareTemplate:
|
||||||
row = BizCompareTemplate(
|
row = BizCompareTemplate(
|
||||||
id=uuid4().hex,
|
id=uuid4().hex,
|
||||||
name=name,
|
name=name,
|
||||||
note="Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP)",
|
note="Built-in ZTE status cutover (ISIS/IF/ARP/ND6/BGP/LLDP)",
|
||||||
created_at=_utcnow(),
|
created_at=_utcnow(),
|
||||||
updated_at=_utcnow(),
|
updated_at=_utcnow(),
|
||||||
)
|
)
|
||||||
_apply_sheets_to_row(row, sheets)
|
_apply_sheets_to_row(row, sheets)
|
||||||
|
_set_template_iface_normalize(row, default_zte_iface_normalize_rules())
|
||||||
|
db.add(row)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_default_zte_config_template(db: Session) -> BizCompareTemplate:
|
||||||
|
name = "ZTE config intent default"
|
||||||
|
row = db.query(BizCompareTemplate).filter(BizCompareTemplate.name == name).one_or_none()
|
||||||
|
sheets = _default_zte_config_sheets()
|
||||||
|
if row:
|
||||||
|
existing = template_metrics(row)
|
||||||
|
want = {s["metric_id"] for s in sheets}
|
||||||
|
have = {s["metric_id"] for s in existing}
|
||||||
|
if want - have:
|
||||||
|
upgraded = list(existing)
|
||||||
|
for s in sheets:
|
||||||
|
if s["metric_id"] not in have:
|
||||||
|
upgraded.append(s)
|
||||||
|
_apply_sheets_to_row(row, upgraded)
|
||||||
|
row.note = "Built-in ZTE config intent (VRF/IF/BGP/L2VPN PW)"
|
||||||
|
row.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
if not template_iface_normalize(row):
|
||||||
|
_set_template_iface_normalize(row, default_zte_iface_normalize_rules())
|
||||||
|
row.updated_at = _utcnow()
|
||||||
|
db.commit()
|
||||||
|
db.refresh(row)
|
||||||
|
return row
|
||||||
|
row = BizCompareTemplate(
|
||||||
|
id=uuid4().hex,
|
||||||
|
name=name,
|
||||||
|
note="Built-in ZTE config intent (VRF/IF/BGP/L2VPN PW)",
|
||||||
|
created_at=_utcnow(),
|
||||||
|
updated_at=_utcnow(),
|
||||||
|
)
|
||||||
|
_apply_sheets_to_row(row, sheets)
|
||||||
|
_set_template_iface_normalize(row, default_zte_iface_normalize_rules())
|
||||||
db.add(row)
|
db.add(row)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(row)
|
db.refresh(row)
|
||||||
|
|
@ -773,16 +910,11 @@ def ensure_default_zte_status_template(db: Session) -> BizCompareTemplate:
|
||||||
|
|
||||||
|
|
||||||
def ensure_default_templates(db: Session) -> None:
|
def ensure_default_templates(db: Session) -> None:
|
||||||
"""Seed built-ins only on first install (empty table).
|
"""Seed the single built-in compare template (most complete status sheet set).
|
||||||
|
|
||||||
Operators own templates after that — do not recreate deleted built-ins
|
Operators own additional templates after that — do not recreate deleted
|
||||||
or overwrite customized sheets on every list call.
|
siblings (port-only / LLDP / VRF / config) on every list call.
|
||||||
"""
|
"""
|
||||||
if db.query(BizCompareTemplate.id).limit(1).first():
|
|
||||||
return
|
|
||||||
ensure_default_cutover_template(db)
|
|
||||||
ensure_default_lldp_template(db)
|
|
||||||
ensure_default_vrf_template(db)
|
|
||||||
ensure_default_zte_status_template(db)
|
ensure_default_zte_status_template(db)
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -871,6 +1003,10 @@ def create_template(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
updated_at=_utcnow(),
|
updated_at=_utcnow(),
|
||||||
)
|
)
|
||||||
_apply_sheets_to_row(t, sheets)
|
_apply_sheets_to_row(t, sheets)
|
||||||
|
if "iface_normalize_rules" in body or "iface_normalize_json" in body:
|
||||||
|
_set_template_iface_normalize(
|
||||||
|
t, body.get("iface_normalize_rules", body.get("iface_normalize_json"))
|
||||||
|
)
|
||||||
db.add(t)
|
db.add(t)
|
||||||
db.commit()
|
db.commit()
|
||||||
return _template_out(t)
|
return _template_out(t)
|
||||||
|
|
@ -884,6 +1020,11 @@ def update_template(db: Session, template_id: str, body: dict[str, Any]) -> dict
|
||||||
t.name = str(body.get("name") or "")[:256]
|
t.name = str(body.get("name") or "")[:256]
|
||||||
if "note" in body:
|
if "note" in body:
|
||||||
t.note = str(body.get("note") or "")[:512]
|
t.note = str(body.get("note") or "")[:512]
|
||||||
|
if "iface_normalize_rules" in body or "iface_normalize_json" in body:
|
||||||
|
_set_template_iface_normalize(
|
||||||
|
t, body.get("iface_normalize_rules", body.get("iface_normalize_json"))
|
||||||
|
)
|
||||||
|
t.updated_at = _utcnow()
|
||||||
if any(
|
if any(
|
||||||
k in body
|
k in body
|
||||||
for k in (
|
for k in (
|
||||||
|
|
@ -1105,12 +1246,22 @@ def validate_mapping(
|
||||||
pmap = _port_map_dict(db, mapping_id)
|
pmap = _port_map_dict(db, mapping_id)
|
||||||
# Validate against first sheet that has iface fields (or first sheet)
|
# Validate against first sheet that has iface fields (or first sheet)
|
||||||
target = next((s for s in sheets if s.get("iface_fields")), sheets[0])
|
target = next((s for s in sheets if s.get("iface_fields")), sheets[0])
|
||||||
before = _load_metric_rows(db, batch_id=before_batch_id, metric_id=target["metric_id"])
|
iface_fields = list(target.get("iface_fields") or [])
|
||||||
after = _load_metric_rows(db, batch_id=after_batch_id, metric_id=target["metric_id"])
|
norm = template_iface_normalize(tpl)
|
||||||
|
before = apply_iface_normalize_rows(
|
||||||
|
_load_metric_rows(db, batch_id=before_batch_id, metric_id=target["metric_id"]),
|
||||||
|
iface_fields=iface_fields,
|
||||||
|
rules=norm,
|
||||||
|
)
|
||||||
|
after = apply_iface_normalize_rows(
|
||||||
|
_load_metric_rows(db, batch_id=after_batch_id, metric_id=target["metric_id"]),
|
||||||
|
iface_fields=iface_fields,
|
||||||
|
rules=norm,
|
||||||
|
)
|
||||||
return mapping_stats(
|
return mapping_stats(
|
||||||
before_rows=before,
|
before_rows=before,
|
||||||
after_rows=after,
|
after_rows=after,
|
||||||
iface_fields=list(target.get("iface_fields") or []),
|
iface_fields=iface_fields,
|
||||||
port_map=pmap,
|
port_map=pmap,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -1232,6 +1383,7 @@ def _run_sheet(
|
||||||
before_batch_id: str,
|
before_batch_id: str,
|
||||||
after_batch_id: str,
|
after_batch_id: str,
|
||||||
port_map: dict[str, str],
|
port_map: dict[str, str],
|
||||||
|
iface_normalize_rules: list[dict[str, str]] | None = None,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
key_fields = list(sheet.get("key_fields") or [])
|
key_fields = list(sheet.get("key_fields") or [])
|
||||||
iface_fields = list(sheet.get("iface_fields") or [])
|
iface_fields = list(sheet.get("iface_fields") or [])
|
||||||
|
|
@ -1261,6 +1413,7 @@ def _run_sheet(
|
||||||
compare_fields=compare_fields,
|
compare_fields=compare_fields,
|
||||||
port_map=port_map,
|
port_map=port_map,
|
||||||
field_rules=field_rules,
|
field_rules=field_rules,
|
||||||
|
iface_normalize_rules=iface_normalize_rules,
|
||||||
)
|
)
|
||||||
summary = dict(result["summary"])
|
summary = dict(result["summary"])
|
||||||
summary["before_raw_count"] = len(before_raw)
|
summary["before_raw_count"] = len(before_raw)
|
||||||
|
|
@ -1302,6 +1455,7 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") ->
|
||||||
raise HTTPException(status_code=400, detail="template_has_no_metrics")
|
raise HTTPException(status_code=400, detail="template_has_no_metrics")
|
||||||
|
|
||||||
pmap = _port_map_dict(db, j.mapping_id)
|
pmap = _port_map_dict(db, j.mapping_id)
|
||||||
|
norm_rules = template_iface_normalize(tpl)
|
||||||
sheet_results: list[dict[str, Any]] = []
|
sheet_results: list[dict[str, Any]] = []
|
||||||
agg = {
|
agg = {
|
||||||
"before_count": 0,
|
"before_count": 0,
|
||||||
|
|
@ -1319,6 +1473,7 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") ->
|
||||||
before_batch_id=before_batch_id,
|
before_batch_id=before_batch_id,
|
||||||
after_batch_id=after_batch_id,
|
after_batch_id=after_batch_id,
|
||||||
port_map=pmap,
|
port_map=pmap,
|
||||||
|
iface_normalize_rules=norm_rules,
|
||||||
)
|
)
|
||||||
sheet_results.append(one)
|
sheet_results.append(one)
|
||||||
s = one["summary"]
|
s = one["summary"]
|
||||||
|
|
|
||||||
137
netx_api/biz_state/iface_normalize.py
Normal file
137
netx_api/biz_state/iface_normalize.py
Normal file
|
|
@ -0,0 +1,137 @@
|
||||||
|
"""Interface name normalize + port-map resolve (exact + parent.subif).
|
||||||
|
|
||||||
|
Normalize rules (template-owned): rewrite type aliases like ``GE`` → ``gei``.
|
||||||
|
Port map (job-owned): rewrite before → after, with subinterface suffix inheritance.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import Any, Mapping, Sequence
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_iface_rules(raw: Any) -> list[dict[str, str]]:
|
||||||
|
"""Accept list of {from,to} or ``from,to`` lines; drop empties; longest ``from`` first."""
|
||||||
|
out: list[dict[str, str]] = []
|
||||||
|
if not raw:
|
||||||
|
return out
|
||||||
|
if isinstance(raw, str):
|
||||||
|
items: list[Any] = []
|
||||||
|
for line in raw.splitlines():
|
||||||
|
line = line.strip()
|
||||||
|
if not line or line.startswith("#"):
|
||||||
|
continue
|
||||||
|
if "," in line:
|
||||||
|
a, b = line.split(",", 1)
|
||||||
|
items.append({"from": a.strip(), "to": b.strip()})
|
||||||
|
elif "\t" in line:
|
||||||
|
a, b = line.split("\t", 1)
|
||||||
|
items.append({"from": a.strip(), "to": b.strip()})
|
||||||
|
else:
|
||||||
|
parts = line.split()
|
||||||
|
if len(parts) >= 2:
|
||||||
|
items.append({"from": parts[0], "to": parts[1]})
|
||||||
|
raw = items
|
||||||
|
if not isinstance(raw, (list, tuple)):
|
||||||
|
return out
|
||||||
|
for item in raw:
|
||||||
|
if not isinstance(item, Mapping):
|
||||||
|
continue
|
||||||
|
fr = str(item.get("from") or item.get("src") or "").strip()
|
||||||
|
to = str(item.get("to") or item.get("dst") or "").strip()
|
||||||
|
if fr and to:
|
||||||
|
out.append({"from": fr, "to": to})
|
||||||
|
# Longest prefix first so XXVGE wins over XGE / GE.
|
||||||
|
out.sort(key=lambda r: len(r["from"]), reverse=True)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _prefix_ok(name: str, prefix: str) -> bool:
|
||||||
|
"""True when ``name`` starts with ``prefix`` (ci) and boundary is safe."""
|
||||||
|
if not name or not prefix:
|
||||||
|
return False
|
||||||
|
n = name
|
||||||
|
p = prefix
|
||||||
|
if len(n) < len(p):
|
||||||
|
return False
|
||||||
|
if n[: len(p)].lower() != p.lower():
|
||||||
|
return False
|
||||||
|
if len(n) == len(p):
|
||||||
|
return True
|
||||||
|
nxt = n[len(p)]
|
||||||
|
return nxt.isdigit() or nxt in "-/"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_iface_name(name: str, rules: Sequence[Mapping[str, str]] | None) -> str:
|
||||||
|
"""Apply first matching type-prefix rule; leave subif / rest intact."""
|
||||||
|
text = str(name or "").strip()
|
||||||
|
if not text or not rules:
|
||||||
|
return text
|
||||||
|
for rule in rules:
|
||||||
|
fr = str(rule.get("from") or "").strip()
|
||||||
|
to = str(rule.get("to") or "").strip()
|
||||||
|
if not fr or not to:
|
||||||
|
continue
|
||||||
|
if _prefix_ok(text, fr):
|
||||||
|
return to + text[len(fr) :]
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def apply_iface_normalize(
|
||||||
|
row: dict[str, Any],
|
||||||
|
*,
|
||||||
|
iface_fields: Sequence[str],
|
||||||
|
rules: Sequence[Mapping[str, str]] | None,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
if not rules or not iface_fields:
|
||||||
|
return dict(row)
|
||||||
|
out = dict(row)
|
||||||
|
for f in iface_fields:
|
||||||
|
key = str(f or "").strip()
|
||||||
|
if not key:
|
||||||
|
continue
|
||||||
|
val = str(out.get(key) or "").strip()
|
||||||
|
if val:
|
||||||
|
out[key] = normalize_iface_name(val, rules)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def apply_iface_normalize_rows(
|
||||||
|
rows: Sequence[Mapping[str, Any]],
|
||||||
|
*,
|
||||||
|
iface_fields: Sequence[str],
|
||||||
|
rules: Sequence[Mapping[str, str]] | None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
return [
|
||||||
|
apply_iface_normalize(dict(r), iface_fields=iface_fields, rules=rules)
|
||||||
|
for r in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_mapped_iface(name: str, port_map: Mapping[str, str] | None) -> str:
|
||||||
|
"""Exact map hit, else parent + ``.suffix`` if parent mapped, else unchanged."""
|
||||||
|
text = str(name or "").strip()
|
||||||
|
if not text or not port_map:
|
||||||
|
return text
|
||||||
|
if text in port_map:
|
||||||
|
return str(port_map[text])
|
||||||
|
if "." not in text:
|
||||||
|
return text
|
||||||
|
parent, suffix = text.rsplit(".", 1)
|
||||||
|
if not parent or not suffix:
|
||||||
|
return text
|
||||||
|
if parent in port_map:
|
||||||
|
return f"{port_map[parent]}.{suffix}"
|
||||||
|
return text
|
||||||
|
|
||||||
|
|
||||||
|
def default_zte_iface_normalize_rules() -> list[dict[str, str]]:
|
||||||
|
"""Common ZTE brief aliases → canonical CLI names."""
|
||||||
|
return normalize_iface_rules(
|
||||||
|
[
|
||||||
|
{"from": "XXVGE", "to": "xxvgei"},
|
||||||
|
{"from": "XGE", "to": "xgei"},
|
||||||
|
{"from": "CGE", "to": "cgei"},
|
||||||
|
{"from": "GE", "to": "gei"},
|
||||||
|
{"from": "SG", "to": "smartgroup"},
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
@ -2,7 +2,9 @@
|
||||||
|
|
||||||
LLDP already works via ``common.lldp_neighbors`` (profile ``parser_id=lldp_neighbors``).
|
LLDP already works via ``common.lldp_neighbors`` (profile ``parser_id=lldp_neighbors``).
|
||||||
|
|
||||||
Stub modules (not registered until implemented)::
|
Shared ``metric_id`` schemas live in ``profiles.py`` (ZTE is the reference
|
||||||
|
implementation). Stub modules keep normalize signatures aligned — do **not**
|
||||||
|
register in ``PARSERS`` until implemented::
|
||||||
|
|
||||||
interface_brief.py arp.py isis_adjacency.py nd6_cache.py bgp_peer.py
|
interface_brief.py arp.py isis_adjacency.py nd6_cache.py bgp_peer.py
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,14 @@ from .pipeline import prefer_fsm
|
||||||
|
|
||||||
RULE_KEYS = ("zte_zxros_show_ip_vrf",)
|
RULE_KEYS = ("zte_zxros_show_ip_vrf",)
|
||||||
|
|
||||||
|
_VRF_LINE_RE = re.compile(
|
||||||
|
r"^\s*(?P<name>[A-Za-z0-9_./:-]+)\s+"
|
||||||
|
r"(?P<rd>\d+:\d+|<not set>|\S+:\S+)\s*"
|
||||||
|
r"(?:(?P<protocols>ipv4(?:,ipv6)?|ipv6(?:,ipv4)?|ipv4|ipv6)\s*)?"
|
||||||
|
r"(?P<vrf_id>\d+)?\s*$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
|
|
@ -22,11 +30,16 @@ def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
if name in seen:
|
if name in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
|
protocols = row_get(r, "PROTOCOLS", "ADDRESS_FAMILY", "protocols")
|
||||||
|
# Legacy templates may put protocols in INTERFACES
|
||||||
|
if not protocols:
|
||||||
|
protocols = row_get(r, "INTERFACES", "interfaces")
|
||||||
out.append(
|
out.append(
|
||||||
{
|
{
|
||||||
"vrf_name": name[:128],
|
"vrf_name": name[:128],
|
||||||
"rd": row_get(r, "DEFAULT_RD", "RD", "rd")[:64],
|
"rd": row_get(r, "RD", "DEFAULT_RD", "rd")[:64],
|
||||||
"protocols": row_get(r, "PROTOCOLS", "ADDRESS_FAMILY", "INTERFACES", "protocols")[:64],
|
"protocols": protocols[:64],
|
||||||
|
"vrf_id": row_get(r, "VRF_ID", "vrf_id")[:32],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
return out
|
return out
|
||||||
|
|
@ -36,16 +49,23 @@ def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for line in str(raw_text or "").splitlines():
|
for line in str(raw_text or "").splitlines():
|
||||||
m = re.match(r"^\s*([A-Za-z0-9_./:-]+)\s+(\d+:\d+|<not set>|\S+:\S+)\s*", line)
|
m = _VRF_LINE_RE.match(line)
|
||||||
if not m:
|
if not m:
|
||||||
continue
|
continue
|
||||||
name = m.group(1)
|
name = m.group("name")
|
||||||
if name.lower() in ("name", "vrf", "vpn-instance", "total"):
|
if name.lower() in ("name", "vrf", "vpn-instance", "total"):
|
||||||
continue
|
continue
|
||||||
if name in seen:
|
if name in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(name)
|
seen.add(name)
|
||||||
out.append({"vrf_name": name[:128], "rd": m.group(2)[:64], "protocols": ""})
|
out.append(
|
||||||
|
{
|
||||||
|
"vrf_name": name[:128],
|
||||||
|
"rd": (m.group("rd") or "")[:64],
|
||||||
|
"protocols": (m.group("protocols") or "")[:64],
|
||||||
|
"vrf_id": (m.group("vrf_id") or "")[:32],
|
||||||
|
}
|
||||||
|
)
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,11 +2,16 @@
|
||||||
|
|
||||||
LLDP already works via ``common.lldp_neighbors`` (``display lldp neighbor``).
|
LLDP already works via ``common.lldp_neighbors`` (``display lldp neighbor``).
|
||||||
|
|
||||||
Stub modules (not registered until implemented)::
|
Shared ``metric_id`` / FieldDef schemas (cross-vendor compare) are defined in
|
||||||
|
``profiles.py`` for ZTE first. Stub modules below keep the same normalize
|
||||||
|
signatures for future Huawei implementations — do **not** register in
|
||||||
|
``PARSERS`` until TextFSM + prefer_fsm paths exist (avoids NotImplementedError
|
||||||
|
at collect time)::
|
||||||
|
|
||||||
interface_brief.py arp.py isis_adjacency.py nd6_cache.py bgp_peer.py
|
interface_brief.py arp.py isis_adjacency.py nd6_cache.py bgp_peer.py
|
||||||
|
|
||||||
When ready, import and add to ``PARSERS``.
|
When ready, import and add to ``PARSERS``. Prefer same field names as ZTE
|
||||||
|
(``bgp_peer``, ``ospf_neighbor``, ``vrrp``, ``ip_route``, …).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
|
||||||
|
|
@ -10,20 +10,54 @@ from typing import Any, Callable
|
||||||
|
|
||||||
from .arp import is_valid_arp_age, normalize_arp
|
from .arp import is_valid_arp_age, normalize_arp
|
||||||
from .bgp_peer import normalize_bgp_peer
|
from .bgp_peer import normalize_bgp_peer
|
||||||
|
from .bgp_route import normalize_bgp_route
|
||||||
|
from .config_bgp_peer import normalize_config_bgp_peer
|
||||||
|
from .config_interface import normalize_config_interface
|
||||||
|
from .config_isis import normalize_config_isis
|
||||||
|
from .config_l2vpn_pw import normalize_config_l2vpn_pw
|
||||||
|
from .config_ospf import normalize_config_ospf
|
||||||
|
from .config_static_route import normalize_config_static_route
|
||||||
|
from .config_vrf import normalize_config_vrf
|
||||||
|
from .evpn_mac import normalize_evpn_mac
|
||||||
from .if_intf import normalize_if_intf, parse_if_intf_vrf_map
|
from .if_intf import normalize_if_intf, parse_if_intf_vrf_map
|
||||||
from .interface_brief import normalize_interface_brief
|
from .interface_brief import normalize_interface_brief
|
||||||
|
from .interface_detail import normalize_interface_detail
|
||||||
|
from .ip_route import normalize_ip_route
|
||||||
|
from .ipv6_route import normalize_ipv6_route
|
||||||
from .isis_adjacency import normalize_isis_adjacency
|
from .isis_adjacency import normalize_isis_adjacency
|
||||||
|
from .l2vpn_mac import normalize_l2vpn_mac
|
||||||
|
from .l2vpn_pw import normalize_l2vpn_pw
|
||||||
from .nd6_cache import normalize_nd6_cache
|
from .nd6_cache import normalize_nd6_cache
|
||||||
|
from .optical_brief import normalize_optical_brief
|
||||||
|
from .ospf_neighbor import normalize_ospf_neighbor
|
||||||
|
from .vrrp import normalize_vrrp
|
||||||
|
|
||||||
NormalizeFn = Callable[..., list[dict[str, Any]]]
|
NormalizeFn = Callable[..., list[dict[str, Any]]]
|
||||||
|
|
||||||
PARSERS: dict[str, NormalizeFn] = {
|
PARSERS: dict[str, NormalizeFn] = {
|
||||||
"isis_adjacency": normalize_isis_adjacency,
|
"isis_adjacency": normalize_isis_adjacency,
|
||||||
"interface_brief": normalize_interface_brief,
|
"interface_brief": normalize_interface_brief,
|
||||||
|
"interface_detail": normalize_interface_detail,
|
||||||
"arp": normalize_arp,
|
"arp": normalize_arp,
|
||||||
"if_intf": normalize_if_intf,
|
"if_intf": normalize_if_intf,
|
||||||
"nd6_cache": normalize_nd6_cache,
|
"nd6_cache": normalize_nd6_cache,
|
||||||
"bgp_peer": normalize_bgp_peer,
|
"bgp_peer": normalize_bgp_peer,
|
||||||
|
"ospf_neighbor": normalize_ospf_neighbor,
|
||||||
|
"vrrp": normalize_vrrp,
|
||||||
|
"optical_brief": normalize_optical_brief,
|
||||||
|
"bgp_route": normalize_bgp_route,
|
||||||
|
"ip_route": normalize_ip_route,
|
||||||
|
"ipv6_route": normalize_ipv6_route,
|
||||||
|
"l2vpn_pw": normalize_l2vpn_pw,
|
||||||
|
"l2vpn_mac": normalize_l2vpn_mac,
|
||||||
|
"evpn_mac": normalize_evpn_mac,
|
||||||
|
"config_vrf": normalize_config_vrf,
|
||||||
|
"config_interface": normalize_config_interface,
|
||||||
|
"config_bgp_peer": normalize_config_bgp_peer,
|
||||||
|
"config_l2vpn_pw": normalize_config_l2vpn_pw,
|
||||||
|
"config_static_route": normalize_config_static_route,
|
||||||
|
"config_ospf": normalize_config_ospf,
|
||||||
|
"config_isis": normalize_config_isis,
|
||||||
}
|
}
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
|
|
@ -31,9 +65,26 @@ __all__ = [
|
||||||
"is_valid_arp_age",
|
"is_valid_arp_age",
|
||||||
"normalize_arp",
|
"normalize_arp",
|
||||||
"normalize_bgp_peer",
|
"normalize_bgp_peer",
|
||||||
|
"normalize_bgp_route",
|
||||||
|
"normalize_config_bgp_peer",
|
||||||
|
"normalize_config_interface",
|
||||||
|
"normalize_config_isis",
|
||||||
|
"normalize_config_l2vpn_pw",
|
||||||
|
"normalize_config_ospf",
|
||||||
|
"normalize_config_static_route",
|
||||||
|
"normalize_config_vrf",
|
||||||
|
"normalize_evpn_mac",
|
||||||
"normalize_if_intf",
|
"normalize_if_intf",
|
||||||
"normalize_interface_brief",
|
"normalize_interface_brief",
|
||||||
|
"normalize_interface_detail",
|
||||||
|
"normalize_ip_route",
|
||||||
|
"normalize_ipv6_route",
|
||||||
"normalize_isis_adjacency",
|
"normalize_isis_adjacency",
|
||||||
|
"normalize_l2vpn_mac",
|
||||||
|
"normalize_l2vpn_pw",
|
||||||
"normalize_nd6_cache",
|
"normalize_nd6_cache",
|
||||||
|
"normalize_optical_brief",
|
||||||
|
"normalize_ospf_neighbor",
|
||||||
|
"normalize_vrrp",
|
||||||
"parse_if_intf_vrf_map",
|
"parse_if_intf_vrf_map",
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,23 +1,39 @@
|
||||||
"""ZTE: show bgp {vpnv4|ipv4|vpnv6} unicast summary."""
|
"""ZTE: show bgp {afi} [unicast] [vrf <vrf>] summary (and l2vpn summaries)."""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import re
|
import re
|
||||||
from typing import Any
|
from typing import Any, Mapping
|
||||||
|
|
||||||
_BGP_PEER_RE = re.compile(
|
from ....lldp_shared import resolve_vendor_key
|
||||||
r"^(?P<nei>\d{1,3}(?:\.\d{1,3}){3})\s+(?P<ver>\d+)\s+(?P<asn>\d+)\s+"
|
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
|
||||||
|
from ..common.pipeline import prefer_fsm
|
||||||
|
|
||||||
|
RULE_KEYS = ("zte_zxros_show_bgp_summary",)
|
||||||
|
|
||||||
|
_PEER_LINE_RE = re.compile(
|
||||||
|
r"^(?P<nei>\d{1,3}(?:\.\d{1,3}){3}|[0-9A-Fa-f:]+)\s+"
|
||||||
|
r"(?P<ver>\d+)\s+(?P<asn>\S+)\s+"
|
||||||
r"(?P<rx>\d+)\s+(?P<tx>\d+)\s+(?P<up>\S+)\s+(?P<state>\S+)\s*$",
|
r"(?P<rx>\d+)\s+(?P<tx>\d+)\s+(?P<up>\S+)\s+(?P<state>\S+)\s*$",
|
||||||
re.I,
|
re.I,
|
||||||
)
|
)
|
||||||
|
_NEI_ONLY_RE = re.compile(r"^(?P<nei>[0-9A-Fa-f:]+)\s*$", re.I)
|
||||||
RULE_KEYS: tuple[str, ...] = ()
|
_CONT_RE = re.compile(
|
||||||
|
r"^\s+(?P<ver>\d+)\s+(?P<asn>\S+)\s+"
|
||||||
|
r"(?P<rx>\d+)\s+(?P<tx>\d+)\s+(?P<up>\S+)\s+(?P<state>\S+)\s*$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_VRF_IN_CMD_RE = re.compile(r"(?i)\bvrf\s+(\S+)")
|
||||||
|
|
||||||
|
|
||||||
def _detect_bgp_afi(command: str, params: dict[str, str] | None) -> str:
|
def _detect_bgp_afi(command: str, params: dict[str, str] | None) -> str:
|
||||||
if params and params.get("afi"):
|
if params and params.get("afi"):
|
||||||
return str(params.get("afi") or "").strip().lower()
|
return str(params.get("afi") or "").strip().lower()
|
||||||
low = str(command or "").lower()
|
low = str(command or "").lower()
|
||||||
|
if "l2vpn" in low and "evpn" in low:
|
||||||
|
return "evpn"
|
||||||
|
if "l2vpn" in low and "vpls" in low:
|
||||||
|
return "vpls"
|
||||||
if "vpnv6" in low:
|
if "vpnv6" in low:
|
||||||
return "vpnv6"
|
return "vpnv6"
|
||||||
if "vpnv4" in low:
|
if "vpnv4" in low:
|
||||||
|
|
@ -29,55 +45,186 @@ def _detect_bgp_afi(command: str, params: dict[str, str] | None) -> str:
|
||||||
return "unknown"
|
return "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_vrf(command: str, params: dict[str, str] | None) -> str:
|
||||||
|
if params and params.get("vrf"):
|
||||||
|
return str(params.get("vrf") or "").strip()
|
||||||
|
m = _VRF_IN_CMD_RE.search(str(command or ""))
|
||||||
|
return m.group(1).strip() if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _state_and_pfx(state_raw: str) -> tuple[str, str]:
|
||||||
|
raw = str(state_raw or "").strip()
|
||||||
|
if raw.isdigit():
|
||||||
|
return "Established", raw
|
||||||
|
# e.g. (NoNeg)
|
||||||
|
return raw, ""
|
||||||
|
|
||||||
|
|
||||||
|
def _peer_row(
|
||||||
|
*,
|
||||||
|
afi: str,
|
||||||
|
vrf: str,
|
||||||
|
nei: str,
|
||||||
|
ver: str,
|
||||||
|
asn: str,
|
||||||
|
rx: str,
|
||||||
|
tx: str,
|
||||||
|
up: str,
|
||||||
|
state_raw: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
state, pfx = _state_and_pfx(state_raw)
|
||||||
|
return {
|
||||||
|
"afi": afi[:32],
|
||||||
|
"vrf": vrf[:128],
|
||||||
|
"neighbor": nei[:128],
|
||||||
|
"ver": ver[:8],
|
||||||
|
"as_num": asn[:16],
|
||||||
|
"msg_rcvd": rx[:32],
|
||||||
|
"msg_send": tx[:32],
|
||||||
|
"up_down": up[:32],
|
||||||
|
"state": state[:64],
|
||||||
|
"pfx_rcd": pfx[:32],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _map_fsm_rows(
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
afi: str,
|
||||||
|
vrf: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
pending = ""
|
||||||
|
for r in rows:
|
||||||
|
nei = row_get(r, "NEIGHBOR", "neighbor")
|
||||||
|
ver = row_get(r, "VER", "ver")
|
||||||
|
if nei and not ver:
|
||||||
|
pending = nei
|
||||||
|
continue
|
||||||
|
if not nei and pending and ver:
|
||||||
|
nei = pending
|
||||||
|
pending = ""
|
||||||
|
if not nei or not ver:
|
||||||
|
continue
|
||||||
|
if nei in seen:
|
||||||
|
continue
|
||||||
|
seen.add(nei)
|
||||||
|
state_raw = row_get(r, "STATE_PFX", "STATE", "state", "pfx_rcd")
|
||||||
|
out.append(
|
||||||
|
_peer_row(
|
||||||
|
afi=afi,
|
||||||
|
vrf=vrf,
|
||||||
|
nei=nei,
|
||||||
|
ver=ver,
|
||||||
|
asn=row_get(r, "ASN", "AS", "as_num"),
|
||||||
|
rx=row_get(r, "MSG_RCVD", "msg_rcvd"),
|
||||||
|
tx=row_get(r, "MSG_SEND", "msg_send"),
|
||||||
|
up=row_get(r, "UP_DOWN", "up_down"),
|
||||||
|
state_raw=state_raw,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_parse(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
afi: str = "unknown",
|
||||||
|
vrf: str = "",
|
||||||
|
**_kw: Any,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
pending = ""
|
||||||
|
for raw in str(raw_text or "").splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
low = line.strip().lower()
|
||||||
|
if low.startswith("bgp router") or low.startswith("local as") or low.startswith("all "):
|
||||||
|
continue
|
||||||
|
if low.startswith("neighbor") and "msg" in low:
|
||||||
|
continue
|
||||||
|
m = _PEER_LINE_RE.match(line.strip())
|
||||||
|
if m:
|
||||||
|
nei = m.group("nei")
|
||||||
|
if nei in seen:
|
||||||
|
continue
|
||||||
|
seen.add(nei)
|
||||||
|
pending = ""
|
||||||
|
out.append(
|
||||||
|
_peer_row(
|
||||||
|
afi=afi,
|
||||||
|
vrf=vrf,
|
||||||
|
nei=nei,
|
||||||
|
ver=m.group("ver"),
|
||||||
|
asn=m.group("asn"),
|
||||||
|
rx=m.group("rx"),
|
||||||
|
tx=m.group("tx"),
|
||||||
|
up=m.group("up"),
|
||||||
|
state_raw=m.group("state"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
m_only = _NEI_ONLY_RE.match(line.strip())
|
||||||
|
if m_only:
|
||||||
|
pending = m_only.group("nei")
|
||||||
|
continue
|
||||||
|
m_cont = _CONT_RE.match(line)
|
||||||
|
if m_cont and pending:
|
||||||
|
nei = pending
|
||||||
|
pending = ""
|
||||||
|
if nei in seen:
|
||||||
|
continue
|
||||||
|
seen.add(nei)
|
||||||
|
out.append(
|
||||||
|
_peer_row(
|
||||||
|
afi=afi,
|
||||||
|
vrf=vrf,
|
||||||
|
nei=nei,
|
||||||
|
ver=m_cont.group("ver"),
|
||||||
|
asn=m_cont.group("asn"),
|
||||||
|
rx=m_cont.group("rx"),
|
||||||
|
tx=m_cont.group("tx"),
|
||||||
|
up=m_cont.group("up"),
|
||||||
|
state_raw=m_cont.group("state"),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def normalize_bgp_peer(
|
def normalize_bgp_peer(
|
||||||
*,
|
*,
|
||||||
raw_text: str,
|
raw_text: str,
|
||||||
fsm_tables=None,
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
vendor: str = "",
|
vendor: str = "",
|
||||||
device_type: str = "",
|
device_type: str = "",
|
||||||
command: str = "",
|
command: str = "",
|
||||||
params: dict[str, str] | None = None,
|
params: dict[str, str] | None = None,
|
||||||
) -> list[dict[str, Any]]:
|
) -> list[dict[str, Any]]:
|
||||||
_ = (vendor, device_type, fsm_tables)
|
|
||||||
afi = _detect_bgp_afi(command, params)
|
afi = _detect_bgp_afi(command, params)
|
||||||
out: list[dict[str, Any]] = []
|
vrf = _detect_vrf(command, params)
|
||||||
seen: set[str] = set()
|
tables = dict(fsm_tables or {})
|
||||||
for raw in str(raw_text or "").splitlines():
|
if not any(tables.get(k) for k in RULE_KEYS):
|
||||||
line = raw.strip()
|
platform = resolve_cli_platform(
|
||||||
if not line:
|
vendor=vendor,
|
||||||
continue
|
device_type=device_type,
|
||||||
low = line.lower()
|
vendor_key=resolve_vendor_key(vendor, device_type),
|
||||||
if low.startswith("bgp router") or low.startswith("local as") or low.startswith("all "):
|
|
||||||
continue
|
|
||||||
if low.startswith("neighbor") and "msg" in low:
|
|
||||||
continue
|
|
||||||
m = _BGP_PEER_RE.match(line)
|
|
||||||
if not m:
|
|
||||||
continue
|
|
||||||
nei = m.group("nei")
|
|
||||||
if nei in seen:
|
|
||||||
continue
|
|
||||||
seen.add(nei)
|
|
||||||
state_raw = m.group("state")
|
|
||||||
if state_raw.isdigit():
|
|
||||||
state = "Established"
|
|
||||||
pfx = state_raw
|
|
||||||
else:
|
|
||||||
state = state_raw
|
|
||||||
pfx = ""
|
|
||||||
out.append(
|
|
||||||
{
|
|
||||||
"afi": afi[:32],
|
|
||||||
"neighbor": nei[:64],
|
|
||||||
"ver": m.group("ver")[:8],
|
|
||||||
"as_num": m.group("asn")[:16],
|
|
||||||
"msg_rcvd": m.group("rx")[:32],
|
|
||||||
"msg_send": m.group("tx")[:32],
|
|
||||||
"up_down": m.group("up")[:32],
|
|
||||||
"state": state[:64],
|
|
||||||
"pfx_rcd": pfx[:32],
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
return out
|
cmd = str(command or "show bgp summary").strip()
|
||||||
|
if platform and cmd:
|
||||||
|
tables = apply_rules(
|
||||||
|
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
|
||||||
|
)
|
||||||
|
|
||||||
|
def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return _map_fsm_rows(rows, afi=afi, vrf=vrf)
|
||||||
|
|
||||||
|
def _hand(*, raw_text: str, **kw: Any) -> list[dict[str, Any]]:
|
||||||
|
return _hand_parse(raw_text=raw_text, afi=afi, vrf=vrf, **kw)
|
||||||
|
|
||||||
|
return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text)
|
||||||
|
|
||||||
|
|
||||||
normalize_bgp_peer.RULE_KEYS = RULE_KEYS
|
normalize_bgp_peer.RULE_KEYS = RULE_KEYS
|
||||||
|
|
|
||||||
199
netx_api/biz_state/parsers/zte/bgp_route.py
Normal file
199
netx_api/biz_state/parsers/zte/bgp_route.py
Normal file
|
|
@ -0,0 +1,199 @@
|
||||||
|
"""ZTE: show bgp … neighbor {in|out} [vrf] routes."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ....lldp_shared import resolve_vendor_key
|
||||||
|
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
|
||||||
|
from ..common.pipeline import prefer_fsm
|
||||||
|
from .bgp_peer import _detect_bgp_afi, _detect_vrf
|
||||||
|
|
||||||
|
RULE_KEYS = ("zte_zxros_show_bgp_neighbor_routes",)
|
||||||
|
|
||||||
|
_ROUTE_RE = re.compile(
|
||||||
|
r"^\s*(?P<flags>[*<>isd]*)\s*"
|
||||||
|
r"(?P<net>\d{1,3}(?:\.\d{1,3}){3}/\d+|[0-9A-Fa-f:]+(?:/\d+)?)\s+"
|
||||||
|
r"(?P<nh>\S+)\s+"
|
||||||
|
r"(?P<rest>.*)$"
|
||||||
|
)
|
||||||
|
_DIR_RE = re.compile(r"(?i)\bneighbor\s+(in|out)\s+")
|
||||||
|
_NEI_RE = re.compile(r"(?i)\bneighbor\s+(?:in|out)\s+(\S+)")
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_direction(command: str, params: dict[str, str] | None) -> str:
|
||||||
|
if params and params.get("direction"):
|
||||||
|
return str(params.get("direction") or "").strip().lower()
|
||||||
|
m = _DIR_RE.search(str(command or ""))
|
||||||
|
return m.group(1).lower() if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_neighbor(command: str, params: dict[str, str] | None) -> str:
|
||||||
|
if params and params.get("neighbor"):
|
||||||
|
return str(params.get("neighbor") or "").strip()
|
||||||
|
m = _NEI_RE.search(str(command or ""))
|
||||||
|
return m.group(1).strip() if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _split_rest(rest: str) -> tuple[str, str, str, str]:
|
||||||
|
"""Parse trailing Metric LocPrf Tag/RtPrf Path columns (some may be blank)."""
|
||||||
|
parts = str(rest or "").split()
|
||||||
|
if not parts:
|
||||||
|
return "", "", "", ""
|
||||||
|
# Last token(s) are AS path + origin; path ends with i|e|?
|
||||||
|
path = " ".join(parts)
|
||||||
|
metric = loc = tag = ""
|
||||||
|
# Heuristic: numeric-only leading fields are metric/loc/tag when present
|
||||||
|
nums: list[str] = []
|
||||||
|
path_parts: list[str] = []
|
||||||
|
for p in parts:
|
||||||
|
if not path_parts and re.fullmatch(r"\d+", p):
|
||||||
|
nums.append(p)
|
||||||
|
else:
|
||||||
|
path_parts.append(p)
|
||||||
|
if len(nums) >= 1:
|
||||||
|
metric = nums[0]
|
||||||
|
if len(nums) >= 2:
|
||||||
|
loc = nums[1]
|
||||||
|
if len(nums) >= 3:
|
||||||
|
tag = nums[2]
|
||||||
|
path = " ".join(path_parts)
|
||||||
|
return metric, loc, tag, path
|
||||||
|
|
||||||
|
|
||||||
|
def _map_fsm_rows(
|
||||||
|
rows: list[dict[str, Any]],
|
||||||
|
*,
|
||||||
|
afi: str,
|
||||||
|
vrf: str,
|
||||||
|
neighbor: str,
|
||||||
|
direction: str,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for r in rows:
|
||||||
|
net = row_get(r, "NETWORK", "network")
|
||||||
|
if not net or net.lower() == "network" or net in seen:
|
||||||
|
continue
|
||||||
|
seen.add(net)
|
||||||
|
path = row_get(r, "PATH", "path")
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"afi": afi[:32],
|
||||||
|
"vrf": vrf[:128],
|
||||||
|
"neighbor": neighbor[:128],
|
||||||
|
"direction": direction[:8],
|
||||||
|
"network": net[:128],
|
||||||
|
"next_hop": row_get(r, "NEXT_HOP", "next_hop")[:128],
|
||||||
|
"metric": row_get(r, "METRIC", "metric")[:32],
|
||||||
|
"loc_prf": row_get(r, "LOC_PRF", "loc_prf")[:32],
|
||||||
|
"tag": row_get(r, "TAG", "RT_PRF", "tag")[:32],
|
||||||
|
"path": path[:256],
|
||||||
|
"status_codes": "",
|
||||||
|
"as_num": "",
|
||||||
|
"state": "",
|
||||||
|
"pfx_rcd": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_parse(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
afi: str = "unknown",
|
||||||
|
vrf: str = "",
|
||||||
|
neighbor: str = "",
|
||||||
|
direction: str = "",
|
||||||
|
**_kw: Any,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in str(raw_text or "").splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
low = line.strip().lower()
|
||||||
|
if low.startswith("network") or "next hop" in low:
|
||||||
|
continue
|
||||||
|
if low.startswith("status") or low.startswith("origin") or low.startswith("routes "):
|
||||||
|
continue
|
||||||
|
if low.startswith("local ") or low.startswith("remote ") or low.startswith("total "):
|
||||||
|
continue
|
||||||
|
if low.startswith("route distinguisher"):
|
||||||
|
continue
|
||||||
|
m = _ROUTE_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
net = m.group("net")
|
||||||
|
if net in seen:
|
||||||
|
continue
|
||||||
|
seen.add(net)
|
||||||
|
metric, loc, tag, path = _split_rest(m.group("rest"))
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"afi": afi[:32],
|
||||||
|
"vrf": vrf[:128],
|
||||||
|
"neighbor": neighbor[:128],
|
||||||
|
"direction": direction[:8],
|
||||||
|
"network": net[:128],
|
||||||
|
"next_hop": m.group("nh")[:128],
|
||||||
|
"metric": metric[:32],
|
||||||
|
"loc_prf": loc[:32],
|
||||||
|
"tag": tag[:32],
|
||||||
|
"path": path[:256],
|
||||||
|
"status_codes": (m.group("flags") or "").strip()[:16],
|
||||||
|
"as_num": "",
|
||||||
|
"state": "",
|
||||||
|
"pfx_rcd": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_bgp_route(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
afi = _detect_bgp_afi(command, params)
|
||||||
|
vrf = _detect_vrf(command, params)
|
||||||
|
neighbor = _detect_neighbor(command, params)
|
||||||
|
direction = _detect_direction(command, params)
|
||||||
|
tables = dict(fsm_tables or {})
|
||||||
|
if not any(tables.get(k) for k in RULE_KEYS):
|
||||||
|
platform = resolve_cli_platform(
|
||||||
|
vendor=vendor,
|
||||||
|
device_type=device_type,
|
||||||
|
vendor_key=resolve_vendor_key(vendor, device_type),
|
||||||
|
)
|
||||||
|
cmd = str(command or "show bgp neighbor routes").strip()
|
||||||
|
if platform and cmd:
|
||||||
|
tables = apply_rules(
|
||||||
|
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
|
||||||
|
)
|
||||||
|
|
||||||
|
def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return _map_fsm_rows(
|
||||||
|
rows, afi=afi, vrf=vrf, neighbor=neighbor, direction=direction
|
||||||
|
)
|
||||||
|
|
||||||
|
def _hand(*, raw_text: str, **kw: Any) -> list[dict[str, Any]]:
|
||||||
|
return _hand_parse(
|
||||||
|
raw_text=raw_text,
|
||||||
|
afi=afi,
|
||||||
|
vrf=vrf,
|
||||||
|
neighbor=neighbor,
|
||||||
|
direction=direction,
|
||||||
|
**kw,
|
||||||
|
)
|
||||||
|
|
||||||
|
return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
normalize_bgp_route.RULE_KEYS = RULE_KEYS
|
||||||
216
netx_api/biz_state/parsers/zte/config_bgp_peer.py
Normal file
216
netx_api/biz_state/parsers/zte/config_bgp_peer.py
Normal file
|
|
@ -0,0 +1,216 @@
|
||||||
|
"""ZTE config intent: ``show running-config bgp`` / MIM ``!<bgp>``.
|
||||||
|
|
||||||
|
Emits one row per ``(afi, vrf, neighbor|peer_group)`` activation under
|
||||||
|
address-family. IP literals go in ``neighbor``; non-IP names (peer-groups)
|
||||||
|
go in ``peer_group``. Never captures password / secret lines.
|
||||||
|
|
||||||
|
Route-maps under an address-family are scoped to that ``(afi, vrf)``;
|
||||||
|
global (top-level) route-maps apply to all AF rows for that neighbor and
|
||||||
|
are overlaid by AF-specific maps when both exist.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .config_common import extract_mim_section, is_secret_line
|
||||||
|
|
||||||
|
RULE_KEYS: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
_NEI_REMOTE_RE = re.compile(r"^\s*neighbor\s+(\S+)\s+remote-as\s+(\S+)\s*$", re.I)
|
||||||
|
_NEI_UPD_RE = re.compile(r"^\s*neighbor\s+(\S+)\s+update-source\s+(\S+)\s*$", re.I)
|
||||||
|
_NEI_PG_RE = re.compile(r"^\s*neighbor\s+(\S+)\s+peer-group(?:\s+(\S+))?\s*$", re.I)
|
||||||
|
_NEI_RM_RE = re.compile(
|
||||||
|
r"^\s*neighbor\s+(\S+)\s+route-map\s+(\S+)\s+(in|out)\s*$", re.I
|
||||||
|
)
|
||||||
|
_NEI_ACT_RE = re.compile(
|
||||||
|
r"^\s*neighbor\s+(\S+)\s+activate(?:\s+(disable))?\s*$", re.I
|
||||||
|
)
|
||||||
|
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
||||||
|
|
||||||
|
_AfKey = tuple[str, str, str] # (afi, vrf, token)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ip_neighbor(token: str) -> bool:
|
||||||
|
"""True for IPv4/IPv6 literals; false for peer-group names (even with ':')."""
|
||||||
|
tok = str(token or "").strip()
|
||||||
|
if not tok:
|
||||||
|
return False
|
||||||
|
if _IPV4_RE.match(tok):
|
||||||
|
return True
|
||||||
|
# IPv6 must look like an address, not ``asn:name`` peer-group labels.
|
||||||
|
if ":" not in tok:
|
||||||
|
return False
|
||||||
|
try:
|
||||||
|
import ipaddress
|
||||||
|
|
||||||
|
ipaddress.ip_address(tok.split("%", 1)[0])
|
||||||
|
return True
|
||||||
|
except ValueError:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _split_neighbor_cols(token: str, info: Mapping[str, str]) -> tuple[str, str]:
|
||||||
|
"""Return ``(neighbor, peer_group)`` columns for a neighbor token."""
|
||||||
|
tok = str(token or "").strip()
|
||||||
|
if _is_ip_neighbor(tok):
|
||||||
|
return tok, str(info.get("peer_group") or "")
|
||||||
|
# Non-IP token is a peer-group name
|
||||||
|
return "", tok
|
||||||
|
|
||||||
|
|
||||||
|
def _merge_info(
|
||||||
|
token: str,
|
||||||
|
*,
|
||||||
|
afi: str,
|
||||||
|
vrf: str,
|
||||||
|
meta: Mapping[str, dict[str, str]],
|
||||||
|
af_rm: Mapping[_AfKey, dict[str, str]],
|
||||||
|
) -> dict[str, str]:
|
||||||
|
"""Global neighbor meta + AF-scoped route-maps (AF wins on conflict)."""
|
||||||
|
info = dict(meta.get(token) or {})
|
||||||
|
scoped = af_rm.get((afi, vrf, token)) or {}
|
||||||
|
for key in ("route_map_in", "route_map_out"):
|
||||||
|
if scoped.get(key):
|
||||||
|
info[key] = scoped[key]
|
||||||
|
return info
|
||||||
|
|
||||||
|
|
||||||
|
def _row(
|
||||||
|
*,
|
||||||
|
afi: str,
|
||||||
|
vrf: str,
|
||||||
|
token: str,
|
||||||
|
act: str,
|
||||||
|
info: Mapping[str, str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
neighbor, peer_group = _split_neighbor_cols(token, info)
|
||||||
|
return {
|
||||||
|
"afi": afi[:32],
|
||||||
|
"vrf": vrf[:128],
|
||||||
|
"neighbor": neighbor[:128],
|
||||||
|
"peer_group": peer_group[:64],
|
||||||
|
"remote_as": str(info.get("remote_as") or "")[:16],
|
||||||
|
"activate": act[:16],
|
||||||
|
"update_source": str(info.get("update_source") or "")[:64],
|
||||||
|
"route_map_in": str(info.get("route_map_in") or "")[:128],
|
||||||
|
"route_map_out": str(info.get("route_map_out") or "")[:128],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_config_bgp_peer(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = (fsm_tables, vendor, device_type, command, params)
|
||||||
|
body = extract_mim_section(raw_text, "bgp")
|
||||||
|
meta: dict[str, dict[str, str]] = {}
|
||||||
|
af_rm: dict[_AfKey, dict[str, str]] = {}
|
||||||
|
afi = ""
|
||||||
|
vrf = ""
|
||||||
|
activations: list[tuple[str, str, str, str]] = []
|
||||||
|
# (afi, vrf, token, activate)
|
||||||
|
|
||||||
|
for raw in body.splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if is_secret_line(line):
|
||||||
|
continue
|
||||||
|
m = _NEI_REMOTE_RE.match(line)
|
||||||
|
if m:
|
||||||
|
nei = m.group(1).strip()
|
||||||
|
meta.setdefault(nei, {})
|
||||||
|
meta[nei]["remote_as"] = m.group(2).strip()
|
||||||
|
continue
|
||||||
|
m = _NEI_UPD_RE.match(line)
|
||||||
|
if m:
|
||||||
|
nei = m.group(1).strip()
|
||||||
|
meta.setdefault(nei, {})
|
||||||
|
meta[nei]["update_source"] = m.group(2).strip()
|
||||||
|
continue
|
||||||
|
m = _NEI_PG_RE.match(line)
|
||||||
|
if m:
|
||||||
|
nei = m.group(1).strip()
|
||||||
|
meta.setdefault(nei, {})
|
||||||
|
group = (m.group(2) or "").strip()
|
||||||
|
if group:
|
||||||
|
# neighbor <ip> peer-group <name>
|
||||||
|
meta[nei]["peer_group"] = group
|
||||||
|
else:
|
||||||
|
# neighbor <name> peer-group → peer-group definition
|
||||||
|
meta[nei]["is_group"] = "1"
|
||||||
|
continue
|
||||||
|
m = _NEI_RM_RE.match(line)
|
||||||
|
if m and not afi:
|
||||||
|
nei = m.group(1).strip()
|
||||||
|
meta.setdefault(nei, {})
|
||||||
|
direction = m.group(3).lower()
|
||||||
|
key = "route_map_in" if direction == "in" else "route_map_out"
|
||||||
|
meta[nei][key] = m.group(2).strip()
|
||||||
|
continue
|
||||||
|
m = re.match(r"^\s*address-family\s+ipv4\s+vrf\s+(\S+)\s*$", line, re.I)
|
||||||
|
if m:
|
||||||
|
afi, vrf = "ipv4", m.group(1).strip()
|
||||||
|
continue
|
||||||
|
m = re.match(r"^\s*address-family\s+ipv6\s+vrf\s+(\S+)\s*$", line, re.I)
|
||||||
|
if m:
|
||||||
|
afi, vrf = "ipv6", m.group(1).strip()
|
||||||
|
continue
|
||||||
|
m = re.match(r"^\s*address-family\s+l2vpn\s+(\S+)\s*$", line, re.I)
|
||||||
|
if m:
|
||||||
|
afi, vrf = f"l2vpn-{m.group(1).lower()}", ""
|
||||||
|
continue
|
||||||
|
m = re.match(r"^\s*address-family\s+(\S+)\s*$", line, re.I)
|
||||||
|
if m:
|
||||||
|
afi, vrf = m.group(1).lower(), ""
|
||||||
|
continue
|
||||||
|
if re.match(r"^\s*\$\s*$", line) and afi:
|
||||||
|
if line.startswith(" ") and not line.startswith(" "):
|
||||||
|
afi, vrf = "", ""
|
||||||
|
continue
|
||||||
|
m = _NEI_ACT_RE.match(line)
|
||||||
|
if m and afi:
|
||||||
|
nei = m.group(1).strip()
|
||||||
|
act = "disable" if m.group(2) else "enable"
|
||||||
|
activations.append((afi, vrf, nei, act))
|
||||||
|
continue
|
||||||
|
m = _NEI_RM_RE.match(line)
|
||||||
|
if m and afi:
|
||||||
|
nei = m.group(1).strip()
|
||||||
|
direction = m.group(3).lower()
|
||||||
|
rk = "route_map_in" if direction == "in" else "route_map_out"
|
||||||
|
slot = af_rm.setdefault((afi, vrf, nei), {})
|
||||||
|
slot[rk] = m.group(2).strip()
|
||||||
|
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str, str, str]] = set()
|
||||||
|
for afi_s, vrf_s, token, act in activations:
|
||||||
|
info = _merge_info(token, afi=afi_s, vrf=vrf_s, meta=meta, af_rm=af_rm)
|
||||||
|
row = _row(afi=afi_s, vrf=vrf_s, token=token, act=act, info=info)
|
||||||
|
key = (row["afi"], row["vrf"], row["neighbor"], row["peer_group"])
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(row)
|
||||||
|
|
||||||
|
# Global peers with remote-as but no AF activate
|
||||||
|
for token, info in meta.items():
|
||||||
|
if not info.get("remote_as"):
|
||||||
|
continue
|
||||||
|
if any(n == token for _, _, n, _ in activations):
|
||||||
|
continue
|
||||||
|
row = _row(afi="global", vrf="", token=token, act="", info=info)
|
||||||
|
key = (row["afi"], row["vrf"], row["neighbor"], row["peer_group"])
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
normalize_config_bgp_peer.RULE_KEYS = RULE_KEYS
|
||||||
46
netx_api/biz_state/parsers/zte/config_common.py
Normal file
46
netx_api/biz_state/parsers/zte/config_common.py
Normal file
|
|
@ -0,0 +1,46 @@
|
||||||
|
"""Helpers for ZTE MIM running-config section parsing."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
|
||||||
|
_MIM_OPEN = re.compile(r"^!<([^/>][^>]*)>\s*$")
|
||||||
|
_MIM_CLOSE = re.compile(r"^!</([^>]+)>\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def extract_mim_section(raw_text: str, section: str) -> str:
|
||||||
|
"""Return body of ``!<section>`` … ``!</section>`` if present; else original text."""
|
||||||
|
name = str(section or "").strip().lower()
|
||||||
|
if not name:
|
||||||
|
return str(raw_text or "")
|
||||||
|
text = str(raw_text or "")
|
||||||
|
lines = text.splitlines()
|
||||||
|
start = -1
|
||||||
|
for i, line in enumerate(lines):
|
||||||
|
m = _MIM_OPEN.match(line.strip())
|
||||||
|
if m and m.group(1).strip().lower() == name:
|
||||||
|
start = i + 1
|
||||||
|
break
|
||||||
|
if start < 0:
|
||||||
|
return text
|
||||||
|
out: list[str] = []
|
||||||
|
for line in lines[start:]:
|
||||||
|
m = _MIM_CLOSE.match(line.strip())
|
||||||
|
if m and m.group(1).strip().lower() == name:
|
||||||
|
break
|
||||||
|
out.append(line)
|
||||||
|
return "\n".join(out)
|
||||||
|
|
||||||
|
|
||||||
|
def is_secret_line(line: str) -> bool:
|
||||||
|
low = str(line or "").lower()
|
||||||
|
return any(
|
||||||
|
k in low
|
||||||
|
for k in (
|
||||||
|
"password",
|
||||||
|
"secret",
|
||||||
|
"encrypted",
|
||||||
|
"authentication-key",
|
||||||
|
"pre-shared-key",
|
||||||
|
)
|
||||||
|
)
|
||||||
148
netx_api/biz_state/parsers/zte/config_interface.py
Normal file
148
netx_api/biz_state/parsers/zte/config_interface.py
Normal file
|
|
@ -0,0 +1,148 @@
|
||||||
|
"""ZTE config intent: ``show running-config if-intf`` / MIM ``!<if-intf>``.
|
||||||
|
|
||||||
|
Duplicate ``interface`` blocks (common for tunnels) are merged: later values
|
||||||
|
override empty ones; ``admin`` always takes the last explicit setting.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .config_common import extract_mim_section, is_secret_line
|
||||||
|
|
||||||
|
RULE_KEYS: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
_IFACE_RE = re.compile(r"^\s*interface\s+(\S+)\s*$", re.I)
|
||||||
|
_VRF_RE = re.compile(r"^\s*ip\s+vrf\s+forwarding\s+(\S+)\s*$", re.I)
|
||||||
|
_IP_RE = re.compile(r"^\s*ip\s+address\s+(\S+)(?:\s+(\S+))?\s*$", re.I)
|
||||||
|
_IP6_RE = re.compile(r"^\s*ipv6\s+address\s+(\S+)\s*$", re.I)
|
||||||
|
_DESC_RE = re.compile(r"^\s*description\s+(.*?)\s*$", re.I)
|
||||||
|
_MTU_RE = re.compile(r"^\s*mtu\s+(\d+)\s*$", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _row_from_cur(cur: dict[str, Any]) -> dict[str, Any]:
|
||||||
|
ips = cur.get("_ips") or []
|
||||||
|
ip6s = cur.get("_ip6s") or []
|
||||||
|
return {
|
||||||
|
"interface": str(cur.get("interface") or "")[:128],
|
||||||
|
"vrf": str(cur.get("vrf") or "")[:128],
|
||||||
|
"description": str(cur.get("description") or "")[:256],
|
||||||
|
"admin": str(cur.get("admin") or "up")[:16],
|
||||||
|
"ip_address": ",".join(ips)[:256],
|
||||||
|
"ipv6_address": ",".join(ip6s)[:256],
|
||||||
|
"mtu": str(cur.get("mtu") or "")[:16],
|
||||||
|
"_admin_set": bool(cur.get("_admin_set")),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _flush(
|
||||||
|
cur: dict[str, Any] | None,
|
||||||
|
out: list[dict[str, Any]],
|
||||||
|
by_iface: dict[str, int],
|
||||||
|
) -> None:
|
||||||
|
if not cur:
|
||||||
|
return
|
||||||
|
iface = str(cur.get("interface") or "")
|
||||||
|
if not iface:
|
||||||
|
return
|
||||||
|
row = _row_from_cur(cur)
|
||||||
|
admin_set = bool(row.pop("_admin_set", False))
|
||||||
|
if iface in by_iface:
|
||||||
|
prev = out[by_iface[iface]]
|
||||||
|
# Only override admin when this block explicitly set shutdown / no shutdown.
|
||||||
|
if admin_set:
|
||||||
|
prev["admin"] = row["admin"]
|
||||||
|
for key in ("vrf", "description", "mtu"):
|
||||||
|
if row[key]:
|
||||||
|
prev[key] = row[key]
|
||||||
|
if row["ip_address"]:
|
||||||
|
existing = [x for x in (prev["ip_address"] or "").split(",") if x]
|
||||||
|
for ip in row["ip_address"].split(","):
|
||||||
|
if ip and ip not in existing:
|
||||||
|
existing.append(ip)
|
||||||
|
prev["ip_address"] = ",".join(existing)[:256]
|
||||||
|
if row["ipv6_address"]:
|
||||||
|
existing = [x for x in (prev["ipv6_address"] or "").split(",") if x]
|
||||||
|
for ip in row["ipv6_address"].split(","):
|
||||||
|
if ip and ip not in existing:
|
||||||
|
existing.append(ip)
|
||||||
|
prev["ipv6_address"] = ",".join(existing)[:256]
|
||||||
|
return
|
||||||
|
by_iface[iface] = len(out)
|
||||||
|
out.append(row)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_config_interface(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = (fsm_tables, vendor, device_type, command, params)
|
||||||
|
body = extract_mim_section(raw_text, "if-intf")
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
by_iface: dict[str, int] = {}
|
||||||
|
cur: dict[str, Any] | None = None
|
||||||
|
for raw in body.splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if is_secret_line(line):
|
||||||
|
continue
|
||||||
|
m = _IFACE_RE.match(line)
|
||||||
|
if m:
|
||||||
|
_flush(cur, out, by_iface)
|
||||||
|
cur = {
|
||||||
|
"interface": m.group(1).strip(),
|
||||||
|
"vrf": "",
|
||||||
|
"description": "",
|
||||||
|
"admin": "up",
|
||||||
|
"_admin_set": False,
|
||||||
|
"mtu": "",
|
||||||
|
"_ips": [],
|
||||||
|
"_ip6s": [],
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
if not cur:
|
||||||
|
continue
|
||||||
|
if re.match(r"^\$\s*$", line):
|
||||||
|
_flush(cur, out, by_iface)
|
||||||
|
cur = None
|
||||||
|
continue
|
||||||
|
low = line.strip().lower()
|
||||||
|
if low == "shutdown":
|
||||||
|
cur["admin"] = "down"
|
||||||
|
cur["_admin_set"] = True
|
||||||
|
continue
|
||||||
|
if low == "no shutdown":
|
||||||
|
cur["admin"] = "up"
|
||||||
|
cur["_admin_set"] = True
|
||||||
|
continue
|
||||||
|
m = _VRF_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["vrf"] = m.group(1).strip()
|
||||||
|
continue
|
||||||
|
m = _DESC_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["description"] = (m.group(1) or "").strip().strip('"')[:256]
|
||||||
|
continue
|
||||||
|
m = _IP_RE.match(line)
|
||||||
|
if m:
|
||||||
|
addr = m.group(1).strip()
|
||||||
|
mask = (m.group(2) or "").strip()
|
||||||
|
cur["_ips"].append(f"{addr}/{mask}" if mask else addr)
|
||||||
|
continue
|
||||||
|
m = _IP6_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["_ip6s"].append(m.group(1).strip())
|
||||||
|
continue
|
||||||
|
m = _MTU_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["mtu"] = m.group(1)
|
||||||
|
_flush(cur, out, by_iface)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
normalize_config_interface.RULE_KEYS = RULE_KEYS
|
||||||
210
netx_api/biz_state/parsers/zte/config_isis.py
Normal file
210
netx_api/biz_state/parsers/zte/config_isis.py
Normal file
|
|
@ -0,0 +1,210 @@
|
||||||
|
"""ZTE config intent: IS-IS from ``!<isis>`` / ``show running-config isis``.
|
||||||
|
|
||||||
|
One row per ``(process, vrf, interface)``. Process-level attrs (area, system-id,
|
||||||
|
router-id, is-type) are copied onto each interface row.
|
||||||
|
Never captures hello-authentication / encrypted secrets.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .config_common import extract_mim_section, is_secret_line
|
||||||
|
|
||||||
|
RULE_KEYS: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
_PROC_RE = re.compile(
|
||||||
|
r"(?i)^\s*router\s+isis\s+(\S+)(?:\s+vrf\s+(\S+))?\s*$"
|
||||||
|
)
|
||||||
|
_AREA_RE = re.compile(r"(?i)^\s*area\s+(\S+)\s*$")
|
||||||
|
_SYS_RE = re.compile(r"(?i)^\s*system-id\s+(\S+)\s*$")
|
||||||
|
_RID_RE = re.compile(r"(?i)^\s*router-id\s+(\S+)\s*$")
|
||||||
|
_ISTYPE_RE = re.compile(r"(?i)^\s*is-type\s+(\S+)\s*$")
|
||||||
|
_IFACE_RE = re.compile(r"(?i)^\s*interface\s+(\S+)\s*$")
|
||||||
|
_CIRCUIT_RE = re.compile(r"(?i)^\s*circuit-type\s+(\S+)\s*$")
|
||||||
|
_NET_RE = re.compile(r"(?i)^\s*network\s+(\S+)\s*$")
|
||||||
|
_METRIC_RE = re.compile(r"(?i)^\s*metric\s+(\d+)\s*$")
|
||||||
|
_V6_METRIC_RE = re.compile(r"(?i)^\s*ipv6\s+metric\s+(\d+)\s*$")
|
||||||
|
_DOLLAR_RE = re.compile(r"^(\s*)\$\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _indent(spaces: str) -> int:
|
||||||
|
return len(spaces.expandtabs(2))
|
||||||
|
|
||||||
|
|
||||||
|
def _flush(
|
||||||
|
proc: dict[str, Any] | None,
|
||||||
|
iface: dict[str, Any] | None,
|
||||||
|
out: list[dict[str, Any]],
|
||||||
|
seen: set[tuple[str, ...]],
|
||||||
|
) -> None:
|
||||||
|
if not proc or not iface:
|
||||||
|
return
|
||||||
|
key = (proc["process_id"], proc["vrf"], iface["interface"])
|
||||||
|
if key in seen:
|
||||||
|
return
|
||||||
|
seen.add(key)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"process_id": str(proc["process_id"])[:32],
|
||||||
|
"vrf": str(proc.get("vrf") or "")[:128],
|
||||||
|
"area": str(proc.get("area") or "")[:64],
|
||||||
|
"system_id": str(proc.get("system_id") or "")[:64],
|
||||||
|
"router_id": str(proc.get("router_id") or "")[:64],
|
||||||
|
"is_type": str(proc.get("is_type") or "")[:32],
|
||||||
|
"interface": str(iface.get("interface") or "")[:128],
|
||||||
|
"circuit_type": str(iface.get("circuit_type") or "")[:32],
|
||||||
|
"network_type": str(iface.get("network_type") or "")[:32],
|
||||||
|
"ip_enable": str(iface.get("ip_enable") or "")[:8],
|
||||||
|
"ipv6_enable": str(iface.get("ipv6_enable") or "")[:8],
|
||||||
|
"metric": str(iface.get("metric") or "")[:16],
|
||||||
|
"ipv6_metric": str(iface.get("ipv6_metric") or "")[:16],
|
||||||
|
"passive": str(iface.get("passive") or "")[:8],
|
||||||
|
"bfd": str(iface.get("bfd") or "")[:16],
|
||||||
|
"ipv6_bfd": str(iface.get("ipv6_bfd") or "")[:16],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_config_isis(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = (fsm_tables, vendor, device_type, command, params)
|
||||||
|
body = extract_mim_section(raw_text, "isis")
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, ...]] = set()
|
||||||
|
proc: dict[str, Any] | None = None
|
||||||
|
iface: dict[str, Any] | None = None
|
||||||
|
in_af = False
|
||||||
|
af_indent = 0
|
||||||
|
|
||||||
|
for raw in body.splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if is_secret_line(line):
|
||||||
|
continue
|
||||||
|
m = _PROC_RE.match(line)
|
||||||
|
if m:
|
||||||
|
_flush(proc, iface, out, seen)
|
||||||
|
iface = None
|
||||||
|
in_af = False
|
||||||
|
af_indent = 0
|
||||||
|
proc = {
|
||||||
|
"process_id": m.group(1).strip(),
|
||||||
|
"vrf": (m.group(2) or "").strip(),
|
||||||
|
"area": "",
|
||||||
|
"system_id": "",
|
||||||
|
"router_id": "",
|
||||||
|
"is_type": "",
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
if not proc:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip address-family subtree (nested $ must not close the process).
|
||||||
|
if in_af:
|
||||||
|
m = _DOLLAR_RE.match(line)
|
||||||
|
if m and _indent(m.group(1)) <= af_indent:
|
||||||
|
in_af = False
|
||||||
|
af_indent = 0
|
||||||
|
continue
|
||||||
|
|
||||||
|
if re.match(r"(?i)^\s*address-family\s+", line):
|
||||||
|
in_af = True
|
||||||
|
af_indent = len(line) - len(line.lstrip(" \t"))
|
||||||
|
continue
|
||||||
|
|
||||||
|
m = _DOLLAR_RE.match(line)
|
||||||
|
if m:
|
||||||
|
ind = _indent(m.group(1))
|
||||||
|
if iface is not None and ind >= 2:
|
||||||
|
_flush(proc, iface, out, seen)
|
||||||
|
iface = None
|
||||||
|
continue
|
||||||
|
_flush(proc, iface, out, seen)
|
||||||
|
iface = None
|
||||||
|
proc = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if iface is not None:
|
||||||
|
m = _CIRCUIT_RE.match(line)
|
||||||
|
if m:
|
||||||
|
iface["circuit_type"] = m.group(1).lower()
|
||||||
|
continue
|
||||||
|
m = _NET_RE.match(line)
|
||||||
|
if m:
|
||||||
|
iface["network_type"] = m.group(1).lower()
|
||||||
|
continue
|
||||||
|
m = _V6_METRIC_RE.match(line)
|
||||||
|
if m:
|
||||||
|
iface["ipv6_metric"] = m.group(1)
|
||||||
|
continue
|
||||||
|
m = _METRIC_RE.match(line)
|
||||||
|
if m:
|
||||||
|
iface["metric"] = m.group(1)
|
||||||
|
continue
|
||||||
|
low = line.strip().lower()
|
||||||
|
if low == "ip router isis" or low.startswith("ip router isis "):
|
||||||
|
iface["ip_enable"] = "yes"
|
||||||
|
continue
|
||||||
|
if low == "ipv6 router isis" or low.startswith("ipv6 router isis "):
|
||||||
|
iface["ipv6_enable"] = "yes"
|
||||||
|
continue
|
||||||
|
if low == "passive-mode" or low == "passive":
|
||||||
|
iface["passive"] = "yes"
|
||||||
|
continue
|
||||||
|
if low == "bfd-enable" or low.startswith("bfd-enable"):
|
||||||
|
iface["bfd"] = "enable"
|
||||||
|
continue
|
||||||
|
if low == "ipv6 bfd-enable" or low.startswith("ipv6 bfd-enable"):
|
||||||
|
iface["ipv6_bfd"] = "enable"
|
||||||
|
continue
|
||||||
|
continue
|
||||||
|
|
||||||
|
m = _IFACE_RE.match(line)
|
||||||
|
if m:
|
||||||
|
_flush(proc, iface, out, seen)
|
||||||
|
iface = {
|
||||||
|
"interface": m.group(1).strip(),
|
||||||
|
"circuit_type": "",
|
||||||
|
"network_type": "",
|
||||||
|
"ip_enable": "",
|
||||||
|
"ipv6_enable": "",
|
||||||
|
"metric": "",
|
||||||
|
"ipv6_metric": "",
|
||||||
|
"passive": "",
|
||||||
|
"bfd": "",
|
||||||
|
"ipv6_bfd": "",
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
m = _AREA_RE.match(line)
|
||||||
|
if m:
|
||||||
|
proc["area"] = m.group(1).strip()
|
||||||
|
continue
|
||||||
|
m = _SYS_RE.match(line)
|
||||||
|
if m:
|
||||||
|
proc["system_id"] = m.group(1).strip()
|
||||||
|
continue
|
||||||
|
m = _RID_RE.match(line)
|
||||||
|
if m:
|
||||||
|
# prefer IPv4-looking router-id at process level
|
||||||
|
rid = m.group(1).strip()
|
||||||
|
if ":" not in rid or not proc.get("router_id"):
|
||||||
|
proc["router_id"] = rid
|
||||||
|
continue
|
||||||
|
m = _ISTYPE_RE.match(line)
|
||||||
|
if m:
|
||||||
|
proc["is_type"] = m.group(1).lower()
|
||||||
|
continue
|
||||||
|
|
||||||
|
_flush(proc, iface, out, seen)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
normalize_config_isis.RULE_KEYS = RULE_KEYS
|
||||||
140
netx_api/biz_state/parsers/zte/config_l2vpn_pw.py
Normal file
140
netx_api/biz_state/parsers/zte/config_l2vpn_pw.py
Normal file
|
|
@ -0,0 +1,140 @@
|
||||||
|
"""ZTE config intent: ``show running-config l2vpn`` / MIM ``!<l2vpn>``.
|
||||||
|
|
||||||
|
One row per ``pseudo-wire`` / ``backup-pw`` under ``vpws`` / ``vpls``.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .config_common import extract_mim_section, is_secret_line
|
||||||
|
|
||||||
|
RULE_KEYS: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
_VPN_RE = re.compile(r'^\s*(vpws|vpls)\s+("?)([^"\s]+)\2\s*$', re.I)
|
||||||
|
_DESC_RE = re.compile(r"^\s*description\s+(.*?)\s*$", re.I)
|
||||||
|
_AP_RE = re.compile(r"^\s*access-point\s+(\S+)\s*$", re.I)
|
||||||
|
_PW_RE = re.compile(r"^\s*pseudo-wire\s+(\S+)", re.I)
|
||||||
|
_BACKUP_PW_RE = re.compile(r"^\s*backup-pw\s+(\S+)", re.I)
|
||||||
|
_NEI_RE = re.compile(
|
||||||
|
r"^\s*neighbou?r\s+(\S+)\s+vcid\s+(\S+)\s*$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_ENC_RE = re.compile(r"^\s*encapsulation\s+(\S+)\s*$", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _flush_pw(
|
||||||
|
cur_vpn: dict[str, Any] | None,
|
||||||
|
cur_pw: dict[str, Any] | None,
|
||||||
|
out: list[dict[str, Any]],
|
||||||
|
seen: set[tuple[str, str]],
|
||||||
|
) -> None:
|
||||||
|
if not cur_vpn or not cur_pw:
|
||||||
|
return
|
||||||
|
pw = str(cur_pw.get("pw_name") or "")
|
||||||
|
vpn = str(cur_vpn.get("vpn_name") or "")
|
||||||
|
key = (vpn, pw)
|
||||||
|
if not pw or not vpn or key in seen:
|
||||||
|
return
|
||||||
|
seen.add(key)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"vpn_type": str(cur_vpn.get("vpn_type") or "")[:16],
|
||||||
|
"vpn_name": vpn[:128],
|
||||||
|
"pw_name": pw[:128],
|
||||||
|
"peer": str(cur_pw.get("peer") or "")[:64],
|
||||||
|
"vcid": str(cur_pw.get("vcid") or "")[:64],
|
||||||
|
"encapsulation": str(cur_pw.get("encapsulation") or "")[:32],
|
||||||
|
"access_point": str(cur_vpn.get("access_point") or "")[:128],
|
||||||
|
"description": str(cur_vpn.get("description") or "")[:256],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _start_pw(
|
||||||
|
cur_vpn: dict[str, Any] | None,
|
||||||
|
cur_pw: dict[str, Any] | None,
|
||||||
|
out: list[dict[str, Any]],
|
||||||
|
seen: set[tuple[str, str]],
|
||||||
|
pw_name: str,
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
_flush_pw(cur_vpn, cur_pw, out, seen)
|
||||||
|
return {"pw_name": pw_name.strip(), "peer": "", "vcid": "", "encapsulation": ""}
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_config_l2vpn_pw(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = (fsm_tables, vendor, device_type, command, params)
|
||||||
|
body = extract_mim_section(raw_text, "l2vpn")
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
cur_vpn: dict[str, Any] | None = None
|
||||||
|
cur_pw: dict[str, Any] | None = None
|
||||||
|
for raw in body.splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if is_secret_line(line):
|
||||||
|
continue
|
||||||
|
m = _VPN_RE.match(line)
|
||||||
|
if m:
|
||||||
|
_flush_pw(cur_vpn, cur_pw, out, seen)
|
||||||
|
cur_pw = None
|
||||||
|
cur_vpn = {
|
||||||
|
"vpn_type": m.group(1).lower(),
|
||||||
|
"vpn_name": m.group(3).strip(),
|
||||||
|
"description": "",
|
||||||
|
"access_point": "",
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
if not cur_vpn:
|
||||||
|
continue
|
||||||
|
if re.match(r"^\$\s*$", line):
|
||||||
|
_flush_pw(cur_vpn, cur_pw, out, seen)
|
||||||
|
cur_pw = None
|
||||||
|
cur_vpn = None
|
||||||
|
continue
|
||||||
|
m = _DESC_RE.match(line)
|
||||||
|
if m and cur_pw is None:
|
||||||
|
cur_vpn["description"] = (m.group(1) or "").strip().strip('"')[:256]
|
||||||
|
continue
|
||||||
|
m = _AP_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur_vpn["access_point"] = m.group(1).strip()
|
||||||
|
continue
|
||||||
|
m = _PW_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur_pw = _start_pw(cur_vpn, cur_pw, out, seen, m.group(1))
|
||||||
|
continue
|
||||||
|
m = _BACKUP_PW_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur_pw = _start_pw(cur_vpn, cur_pw, out, seen, m.group(1))
|
||||||
|
continue
|
||||||
|
# indented $ closes nested block; flush PW once peer is known so
|
||||||
|
# backup-pw / redundancy neighbours do not overwrite the primary.
|
||||||
|
if re.match(r"^\s+\$\s*$", line):
|
||||||
|
if cur_pw and cur_pw.get("peer"):
|
||||||
|
_flush_pw(cur_vpn, cur_pw, out, seen)
|
||||||
|
cur_pw = None
|
||||||
|
continue
|
||||||
|
if not cur_pw:
|
||||||
|
continue
|
||||||
|
m = _NEI_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur_pw["peer"] = m.group(1).strip()
|
||||||
|
cur_pw["vcid"] = m.group(2).strip()
|
||||||
|
continue
|
||||||
|
m = _ENC_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur_pw["encapsulation"] = m.group(1).strip()
|
||||||
|
_flush_pw(cur_vpn, cur_pw, out, seen)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
normalize_config_l2vpn_pw.RULE_KEYS = RULE_KEYS
|
||||||
267
netx_api/biz_state/parsers/zte/config_ospf.py
Normal file
267
netx_api/biz_state/parsers/zte/config_ospf.py
Normal file
|
|
@ -0,0 +1,267 @@
|
||||||
|
"""ZTE config intent: OSPFv2/v3 from ``!<ospfv2>`` / ``!<ospfv3>``.
|
||||||
|
|
||||||
|
One row per ``(af, process, vrf, area, interface)``. Areas with no interfaces
|
||||||
|
still emit a row with empty ``interface``. Redistribute is process-scoped and
|
||||||
|
applied when the process block closes (it often appears after areas).
|
||||||
|
Never captures digest keys / encrypted auth material.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .config_common import extract_mim_section, is_secret_line
|
||||||
|
|
||||||
|
RULE_KEYS: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
_V2_RE = re.compile(r"(?i)^\s*router\s+ospf\s+(\S+)(?:\s+vrf\s+(\S+))?\s*$")
|
||||||
|
_V3_RE = re.compile(r"(?i)^\s*ipv6\s+router\s+ospf\s+(\S+)(?:\s+vrf\s+(\S+))?\s*$")
|
||||||
|
_RID_RE = re.compile(r"(?i)^\s*router-id\s+(\S+)\s*$")
|
||||||
|
_AREA_RE = re.compile(r"(?i)^\s*area\s+(\S+)\s*$")
|
||||||
|
_IFACE_RE = re.compile(r"(?i)^\s*interface\s+(\S+)\s*$")
|
||||||
|
_COST_RE = re.compile(r"(?i)^\s*cost\s+(\d+)\s*$")
|
||||||
|
_HELLO_RE = re.compile(r"(?i)^\s*hello-interval\s+(\d+)\s*$")
|
||||||
|
_DEAD_RE = re.compile(r"(?i)^\s*dead-interval\s+(\d+)\s*$")
|
||||||
|
_NET_RE = re.compile(r"(?i)^\s*network\s+(\S+)\s*$")
|
||||||
|
_REDIST_RE = re.compile(r"(?i)^\s*redistribute\s+(\S+)")
|
||||||
|
_DOLLAR_RE = re.compile(r"^(\s*)\$\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _indent(spaces: str) -> int:
|
||||||
|
return len(spaces.expandtabs(2))
|
||||||
|
|
||||||
|
|
||||||
|
def _queue_iface(
|
||||||
|
proc: dict[str, Any],
|
||||||
|
area: dict[str, Any],
|
||||||
|
iface: dict[str, Any],
|
||||||
|
) -> None:
|
||||||
|
proc.setdefault("_pending", []).append(
|
||||||
|
{
|
||||||
|
"area": str(area.get("area") or ""),
|
||||||
|
"area_type": str(area.get("area_type") or "normal"),
|
||||||
|
"interface": str(iface.get("interface") or ""),
|
||||||
|
"network_type": str(iface.get("network_type") or ""),
|
||||||
|
"cost": str(iface.get("cost") or ""),
|
||||||
|
"hello_interval": str(iface.get("hello_interval") or ""),
|
||||||
|
"dead_interval": str(iface.get("dead_interval") or ""),
|
||||||
|
"bfd": str(iface.get("bfd") or ""),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _emit_process(
|
||||||
|
proc: dict[str, Any] | None,
|
||||||
|
out: list[dict[str, Any]],
|
||||||
|
seen: set[tuple[str, ...]],
|
||||||
|
) -> None:
|
||||||
|
if not proc:
|
||||||
|
return
|
||||||
|
redist = ",".join(sorted(proc.get("_redistribute") or []))[:256]
|
||||||
|
for item in proc.get("_pending") or []:
|
||||||
|
key = (
|
||||||
|
proc["af"],
|
||||||
|
proc["process_id"],
|
||||||
|
proc["vrf"],
|
||||||
|
item["area"],
|
||||||
|
item["interface"],
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"af": proc["af"][:8],
|
||||||
|
"process_id": str(proc["process_id"])[:32],
|
||||||
|
"vrf": str(proc.get("vrf") or "")[:128],
|
||||||
|
"router_id": str(proc.get("router_id") or "")[:64],
|
||||||
|
"area": item["area"][:64],
|
||||||
|
"area_type": item["area_type"][:32],
|
||||||
|
"interface": item["interface"][:128],
|
||||||
|
"network_type": item["network_type"][:32],
|
||||||
|
"cost": item["cost"][:16],
|
||||||
|
"hello_interval": item["hello_interval"][:16],
|
||||||
|
"dead_interval": item["dead_interval"][:16],
|
||||||
|
"bfd": item["bfd"][:16],
|
||||||
|
"redistribute": redist,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _close_area(proc: dict[str, Any] | None, area: dict[str, Any] | None) -> None:
|
||||||
|
if not proc or not area:
|
||||||
|
return
|
||||||
|
if int(area.get("_iface_count") or 0) == 0:
|
||||||
|
_queue_iface(
|
||||||
|
proc,
|
||||||
|
area,
|
||||||
|
{
|
||||||
|
"interface": "",
|
||||||
|
"network_type": "",
|
||||||
|
"cost": "",
|
||||||
|
"hello_interval": "",
|
||||||
|
"dead_interval": "",
|
||||||
|
"bfd": "",
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_ospf_body(body: str, *, af: str) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, ...]] = set()
|
||||||
|
proc: dict[str, Any] | None = None
|
||||||
|
area: dict[str, Any] | None = None
|
||||||
|
iface: dict[str, Any] | None = None
|
||||||
|
head_re = _V3_RE if af == "ipv6" else _V2_RE
|
||||||
|
|
||||||
|
for raw in body.splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if is_secret_line(line):
|
||||||
|
continue
|
||||||
|
m = head_re.match(line)
|
||||||
|
if m:
|
||||||
|
if iface and proc and area:
|
||||||
|
_queue_iface(proc, area, iface)
|
||||||
|
_close_area(proc, area)
|
||||||
|
_emit_process(proc, out, seen)
|
||||||
|
iface = None
|
||||||
|
area = None
|
||||||
|
proc = {
|
||||||
|
"af": af,
|
||||||
|
"process_id": m.group(1).strip(),
|
||||||
|
"vrf": (m.group(2) or "").strip(),
|
||||||
|
"router_id": "",
|
||||||
|
"_redistribute": set(),
|
||||||
|
"_pending": [],
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
if not proc:
|
||||||
|
continue
|
||||||
|
|
||||||
|
m = _DOLLAR_RE.match(line)
|
||||||
|
if m:
|
||||||
|
ind = _indent(m.group(1))
|
||||||
|
if iface is not None and ind >= 4:
|
||||||
|
_queue_iface(proc, area, iface) # type: ignore[arg-type]
|
||||||
|
iface = None
|
||||||
|
elif area is not None and ind >= 2:
|
||||||
|
if iface and area:
|
||||||
|
_queue_iface(proc, area, iface)
|
||||||
|
iface = None
|
||||||
|
_close_area(proc, area)
|
||||||
|
area = None
|
||||||
|
else:
|
||||||
|
if iface and area:
|
||||||
|
_queue_iface(proc, area, iface)
|
||||||
|
iface = None
|
||||||
|
_close_area(proc, area)
|
||||||
|
area = None
|
||||||
|
_emit_process(proc, out, seen)
|
||||||
|
proc = None
|
||||||
|
continue
|
||||||
|
|
||||||
|
if iface is not None:
|
||||||
|
m = _COST_RE.match(line)
|
||||||
|
if m:
|
||||||
|
iface["cost"] = m.group(1)
|
||||||
|
continue
|
||||||
|
m = _HELLO_RE.match(line)
|
||||||
|
if m:
|
||||||
|
iface["hello_interval"] = m.group(1)
|
||||||
|
continue
|
||||||
|
m = _DEAD_RE.match(line)
|
||||||
|
if m:
|
||||||
|
iface["dead_interval"] = m.group(1)
|
||||||
|
continue
|
||||||
|
m = _NET_RE.match(line)
|
||||||
|
if m:
|
||||||
|
iface["network_type"] = m.group(1).lower()
|
||||||
|
continue
|
||||||
|
low = line.strip().lower()
|
||||||
|
if low == "bfd" or low.startswith("bfd interval") or low.startswith("bfd enable"):
|
||||||
|
iface["bfd"] = "enable"
|
||||||
|
continue
|
||||||
|
if low == "bfd disable":
|
||||||
|
iface["bfd"] = "disable"
|
||||||
|
continue
|
||||||
|
continue
|
||||||
|
|
||||||
|
m = _AREA_RE.match(line)
|
||||||
|
if m:
|
||||||
|
if iface and area:
|
||||||
|
_queue_iface(proc, area, iface)
|
||||||
|
iface = None
|
||||||
|
_close_area(proc, area)
|
||||||
|
area = {
|
||||||
|
"area": m.group(1).strip(),
|
||||||
|
"area_type": "normal",
|
||||||
|
"_iface_count": 0,
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
|
||||||
|
if area is not None:
|
||||||
|
m = _IFACE_RE.match(line)
|
||||||
|
if m:
|
||||||
|
if iface:
|
||||||
|
_queue_iface(proc, area, iface)
|
||||||
|
iface = {
|
||||||
|
"interface": m.group(1).strip(),
|
||||||
|
"network_type": "",
|
||||||
|
"cost": "",
|
||||||
|
"hello_interval": "",
|
||||||
|
"dead_interval": "",
|
||||||
|
"bfd": "",
|
||||||
|
}
|
||||||
|
area["_iface_count"] = int(area.get("_iface_count") or 0) + 1
|
||||||
|
continue
|
||||||
|
low = line.strip().lower()
|
||||||
|
if low.startswith("stub"):
|
||||||
|
area["area_type"] = "stub"
|
||||||
|
continue
|
||||||
|
if low.startswith("nssa"):
|
||||||
|
area["area_type"] = "nssa"
|
||||||
|
continue
|
||||||
|
|
||||||
|
# process-level (also after areas closed)
|
||||||
|
if iface is None:
|
||||||
|
m = _RID_RE.match(line)
|
||||||
|
if m:
|
||||||
|
proc["router_id"] = m.group(1).strip()
|
||||||
|
continue
|
||||||
|
m = _REDIST_RE.match(line)
|
||||||
|
if m:
|
||||||
|
proc["_redistribute"].add(m.group(1).lower())
|
||||||
|
continue
|
||||||
|
|
||||||
|
if iface and proc and area:
|
||||||
|
_queue_iface(proc, area, iface)
|
||||||
|
_close_area(proc, area)
|
||||||
|
_emit_process(proc, out, seen)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_config_ospf(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = (fsm_tables, vendor, device_type, params)
|
||||||
|
cmd = str(command or "").lower()
|
||||||
|
text = str(raw_text or "")
|
||||||
|
if "ospfv3" in cmd:
|
||||||
|
return _parse_ospf_body(extract_mim_section(text, "ospfv3"), af="ipv6")
|
||||||
|
if "ospfv2" in cmd or re.search(r"(?i)running-config\s+ospf(?:v2)?\b", cmd):
|
||||||
|
return _parse_ospf_body(extract_mim_section(text, "ospfv2"), af="ipv4")
|
||||||
|
if re.search(r"(?i)^!<ospfv3>\s*$", text, re.M) and not re.search(
|
||||||
|
r"(?i)^!<ospfv2>\s*$", text, re.M
|
||||||
|
):
|
||||||
|
return _parse_ospf_body(extract_mim_section(text, "ospfv3"), af="ipv6")
|
||||||
|
return _parse_ospf_body(extract_mim_section(text, "ospfv2"), af="ipv4")
|
||||||
|
|
||||||
|
|
||||||
|
normalize_config_ospf.RULE_KEYS = RULE_KEYS
|
||||||
283
netx_api/biz_state/parsers/zte/config_static_route.py
Normal file
283
netx_api/biz_state/parsers/zte/config_static_route.py
Normal file
|
|
@ -0,0 +1,283 @@
|
||||||
|
"""ZTE config intent: static routes from ``!<static>`` / ``!<ipv6-static-route>``.
|
||||||
|
|
||||||
|
Commands:
|
||||||
|
``show running-config static``
|
||||||
|
``show running-config ipv6-static-route``
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .config_common import extract_mim_section, is_secret_line
|
||||||
|
|
||||||
|
RULE_KEYS: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
_V4_HEAD = re.compile(
|
||||||
|
r"(?i)^\s*ip\s+route(?:\s+vrf\s+(?P<vrf>\S+))?\s+"
|
||||||
|
r"(?P<prefix>\d{1,3}(?:\.\d{1,3}){3})\s+"
|
||||||
|
r"(?P<mask>\d{1,3}(?:\.\d{1,3}){3})\s+"
|
||||||
|
r"(?P<rest>.*)$"
|
||||||
|
)
|
||||||
|
_V6_HEAD = re.compile(
|
||||||
|
r"(?i)^\s*ipv6\s+route(?:\s+vrf\s+(?P<vrf>\S+))?\s+"
|
||||||
|
r"(?P<prefix>\S+)\s+"
|
||||||
|
r"(?P<rest>.*)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
_IPV4_RE = re.compile(r"^\d{1,3}(?:\.\d{1,3}){3}$")
|
||||||
|
_IFACE_RE = re.compile(
|
||||||
|
r"(?i)^(null\d*|gei-\S+|xgei-\S+|cgei-\S+|bvi\S*|vlan\S*|smartgroup\S*|"
|
||||||
|
r"loopback\S*|mgmt\S*|pos\S*|eth\S*|tunnel\S*|irb\S*|ve\S*)$"
|
||||||
|
)
|
||||||
|
_KW = frozenset(
|
||||||
|
{
|
||||||
|
"name",
|
||||||
|
"track",
|
||||||
|
"tag",
|
||||||
|
"metric",
|
||||||
|
"distance",
|
||||||
|
"preference",
|
||||||
|
"nexthop-vrf",
|
||||||
|
"bfd",
|
||||||
|
"enable",
|
||||||
|
"disable",
|
||||||
|
"permanent",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _is_ipv4(tok: str) -> bool:
|
||||||
|
return bool(_IPV4_RE.match(tok or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _is_iface(tok: str) -> bool:
|
||||||
|
return bool(_IFACE_RE.match(tok or ""))
|
||||||
|
|
||||||
|
|
||||||
|
def _take_kw(tokens: list[str], i: int, row: dict[str, str]) -> int:
|
||||||
|
"""Consume optional keyword/value pairs starting at ``i``; return new index."""
|
||||||
|
n = len(tokens)
|
||||||
|
while i < n:
|
||||||
|
low = tokens[i].lower()
|
||||||
|
if low in ("bfd",):
|
||||||
|
# bfd [enable|disable]
|
||||||
|
row["bfd"] = "enable"
|
||||||
|
i += 1
|
||||||
|
if i < n and tokens[i].lower() in ("enable", "disable"):
|
||||||
|
row["bfd"] = tokens[i].lower()
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if low in ("enable", "disable", "permanent"):
|
||||||
|
if low == "permanent":
|
||||||
|
row["permanent"] = "yes"
|
||||||
|
i += 1
|
||||||
|
continue
|
||||||
|
if low in ("name", "track", "tag", "metric", "distance", "preference", "nexthop-vrf"):
|
||||||
|
if i + 1 >= n:
|
||||||
|
break
|
||||||
|
val = tokens[i + 1]
|
||||||
|
if low == "nexthop-vrf":
|
||||||
|
row["nexthop_vrf"] = val
|
||||||
|
elif low == "name":
|
||||||
|
row["route_name"] = val
|
||||||
|
else:
|
||||||
|
row[low] = val
|
||||||
|
i += 2
|
||||||
|
continue
|
||||||
|
break
|
||||||
|
return i
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_v4_rest(rest: str) -> dict[str, str]:
|
||||||
|
tokens = (rest or "").split()
|
||||||
|
row: dict[str, str] = {
|
||||||
|
"next_hop": "",
|
||||||
|
"interface": "",
|
||||||
|
"nexthop_vrf": "",
|
||||||
|
"metric": "",
|
||||||
|
"track": "",
|
||||||
|
"tag": "",
|
||||||
|
"route_name": "",
|
||||||
|
"bfd": "",
|
||||||
|
"distance": "",
|
||||||
|
}
|
||||||
|
if not tokens:
|
||||||
|
return row
|
||||||
|
i = 0
|
||||||
|
# nexthop-vrf without explicit IP
|
||||||
|
if tokens[0].lower() == "nexthop-vrf" and len(tokens) >= 2:
|
||||||
|
row["nexthop_vrf"] = tokens[1]
|
||||||
|
_take_kw(tokens, 2, row)
|
||||||
|
return row
|
||||||
|
if _is_iface(tokens[0]):
|
||||||
|
row["interface"] = tokens[0]
|
||||||
|
i = 1
|
||||||
|
if i < len(tokens) and _is_ipv4(tokens[i]) and tokens[i].lower() not in _KW:
|
||||||
|
row["next_hop"] = tokens[i]
|
||||||
|
i += 1
|
||||||
|
elif _is_ipv4(tokens[0]):
|
||||||
|
row["next_hop"] = tokens[0]
|
||||||
|
i = 1
|
||||||
|
_take_kw(tokens, i, row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_v6_rest(rest: str) -> dict[str, str]:
|
||||||
|
tokens = (rest or "").split()
|
||||||
|
row: dict[str, str] = {
|
||||||
|
"next_hop": "",
|
||||||
|
"interface": "",
|
||||||
|
"nexthop_vrf": "",
|
||||||
|
"metric": "",
|
||||||
|
"track": "",
|
||||||
|
"tag": "",
|
||||||
|
"route_name": "",
|
||||||
|
"bfd": "",
|
||||||
|
"distance": "",
|
||||||
|
}
|
||||||
|
if not tokens:
|
||||||
|
return row
|
||||||
|
i = 0
|
||||||
|
if tokens[0].lower() == "nexthop-vrf" and len(tokens) >= 2:
|
||||||
|
row["nexthop_vrf"] = tokens[1]
|
||||||
|
_take_kw(tokens, 2, row)
|
||||||
|
return row
|
||||||
|
if _is_iface(tokens[0]):
|
||||||
|
row["interface"] = tokens[0]
|
||||||
|
i = 1
|
||||||
|
if i < len(tokens) and ":" in tokens[i] and tokens[i].lower() not in _KW:
|
||||||
|
row["next_hop"] = tokens[i]
|
||||||
|
i += 1
|
||||||
|
elif ":" in tokens[0]:
|
||||||
|
row["next_hop"] = tokens[0]
|
||||||
|
i = 1
|
||||||
|
_take_kw(tokens, i, row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
|
||||||
|
def _row(
|
||||||
|
*,
|
||||||
|
af: str,
|
||||||
|
vrf: str,
|
||||||
|
prefix: str,
|
||||||
|
mask: str,
|
||||||
|
extra: dict[str, str],
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"af": af[:8],
|
||||||
|
"vrf": (vrf or "")[:128],
|
||||||
|
"prefix": prefix[:128],
|
||||||
|
"mask": (mask or "")[:64],
|
||||||
|
"next_hop": (extra.get("next_hop") or "")[:64],
|
||||||
|
"interface": (extra.get("interface") or "")[:128],
|
||||||
|
"nexthop_vrf": (extra.get("nexthop_vrf") or "")[:128],
|
||||||
|
"metric": (extra.get("metric") or "")[:16],
|
||||||
|
"bfd": (extra.get("bfd") or "")[:16],
|
||||||
|
"track": (extra.get("track") or "")[:64],
|
||||||
|
"route_name": (extra.get("route_name") or "")[:128],
|
||||||
|
"tag": (extra.get("tag") or "")[:32],
|
||||||
|
"distance": (extra.get("distance") or extra.get("preference") or "")[:16],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_v4(body: str) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, ...]] = set()
|
||||||
|
for raw in body.splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if is_secret_line(line):
|
||||||
|
continue
|
||||||
|
m = _V4_HEAD.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
extra = _parse_v4_rest(m.group("rest") or "")
|
||||||
|
row = _row(
|
||||||
|
af="ipv4",
|
||||||
|
vrf=(m.group("vrf") or "").strip(),
|
||||||
|
prefix=m.group("prefix").strip(),
|
||||||
|
mask=m.group("mask").strip(),
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
key = (
|
||||||
|
row["vrf"],
|
||||||
|
row["prefix"],
|
||||||
|
row["mask"],
|
||||||
|
row["next_hop"],
|
||||||
|
row["interface"],
|
||||||
|
row["nexthop_vrf"],
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_v6(body: str) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, ...]] = set()
|
||||||
|
for raw in body.splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if is_secret_line(line):
|
||||||
|
continue
|
||||||
|
m = _V6_HEAD.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
extra = _parse_v6_rest(m.group("rest") or "")
|
||||||
|
row = _row(
|
||||||
|
af="ipv6",
|
||||||
|
vrf=(m.group("vrf") or "").strip(),
|
||||||
|
prefix=m.group("prefix").strip(),
|
||||||
|
mask="",
|
||||||
|
extra=extra,
|
||||||
|
)
|
||||||
|
key = (
|
||||||
|
row["vrf"],
|
||||||
|
row["prefix"],
|
||||||
|
row["next_hop"],
|
||||||
|
row["interface"],
|
||||||
|
row["nexthop_vrf"],
|
||||||
|
)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(row)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_af(command: str, raw_text: str) -> str:
|
||||||
|
cmd = str(command or "").lower()
|
||||||
|
if "ipv6-static" in cmd or "ipv6 static" in cmd:
|
||||||
|
return "ipv6"
|
||||||
|
if re.search(r"(?i)^\s*show\s+running-config\s+static\b", cmd):
|
||||||
|
return "ipv4"
|
||||||
|
# Full dump / ambiguous: prefer section presence
|
||||||
|
text = str(raw_text or "")
|
||||||
|
if re.search(r"(?i)^!<ipv6-static-route>\s*$", text, re.M) and not re.search(
|
||||||
|
r"(?i)^!<static>\s*$", text, re.M
|
||||||
|
):
|
||||||
|
return "ipv6"
|
||||||
|
return "ipv4"
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_config_static_route(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = (fsm_tables, vendor, device_type, params)
|
||||||
|
af = _detect_af(command, raw_text)
|
||||||
|
if af == "ipv6":
|
||||||
|
body = extract_mim_section(raw_text, "ipv6-static-route")
|
||||||
|
return _parse_v6(body)
|
||||||
|
body = extract_mim_section(raw_text, "static")
|
||||||
|
return _parse_v4(body)
|
||||||
|
|
||||||
|
|
||||||
|
normalize_config_static_route.RULE_KEYS = RULE_KEYS
|
||||||
107
netx_api/biz_state/parsers/zte/config_vrf.py
Normal file
107
netx_api/biz_state/parsers/zte/config_vrf.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"""ZTE config intent: ``show running-config vrf`` / MIM ``!<vrf>``."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from .config_common import extract_mim_section, is_secret_line
|
||||||
|
|
||||||
|
RULE_KEYS: tuple[str, ...] = ()
|
||||||
|
|
||||||
|
_VRF_RE = re.compile(r"^\s*ip\s+vrf\s+(\S+)\s*$", re.I)
|
||||||
|
_RD_RE = re.compile(r"^\s*rd\s+(\S+)\s*$", re.I)
|
||||||
|
_DESC_RE = re.compile(r'^\s*description\s+("?)(.*?)\1\s*$', re.I)
|
||||||
|
_AF_RE = re.compile(r"^\s*address-family\s+(ipv4|ipv6)\s*$", re.I)
|
||||||
|
_RT_RE = re.compile(r"^\s*route-target\s+(export|import)\s+(\S+)\s*$", re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def _flush(cur: dict[str, Any] | None, out: list[dict[str, Any]], seen: set[str]) -> None:
|
||||||
|
if not cur:
|
||||||
|
return
|
||||||
|
name = str(cur.get("vrf_name") or "")
|
||||||
|
if not name or name in seen:
|
||||||
|
return
|
||||||
|
seen.add(name)
|
||||||
|
afs = sorted(cur.get("_afs") or [])
|
||||||
|
exports = sorted(cur.get("_rt_export") or [])
|
||||||
|
imports = sorted(cur.get("_rt_import") or [])
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"vrf_name": name[:128],
|
||||||
|
"rd": str(cur.get("rd") or "")[:64],
|
||||||
|
"description": str(cur.get("description") or "")[:256],
|
||||||
|
"address_families": ",".join(afs)[:64],
|
||||||
|
"rt_export": ",".join(exports)[:512],
|
||||||
|
"rt_import": ",".join(imports)[:512],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_config_vrf(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = (fsm_tables, vendor, device_type, command, params)
|
||||||
|
body = extract_mim_section(raw_text, "vrf")
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
cur: dict[str, Any] | None = None
|
||||||
|
for raw in body.splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if is_secret_line(line):
|
||||||
|
continue
|
||||||
|
m = _VRF_RE.match(line)
|
||||||
|
if m:
|
||||||
|
_flush(cur, out, seen)
|
||||||
|
cur = {
|
||||||
|
"vrf_name": m.group(1).strip(),
|
||||||
|
"rd": "",
|
||||||
|
"description": "",
|
||||||
|
"_afs": set(),
|
||||||
|
"_rt_export": set(),
|
||||||
|
"_rt_import": set(),
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
if not cur:
|
||||||
|
continue
|
||||||
|
if line.strip() == "$" and not line.startswith(" "):
|
||||||
|
# top-level close for vrf block (indent 0 $)
|
||||||
|
if not line.startswith(" ") and not line.startswith("\t"):
|
||||||
|
_flush(cur, out, seen)
|
||||||
|
cur = None
|
||||||
|
continue
|
||||||
|
# ZTE uses bare `$` at column 0 to end blocks; nested `$` are indented
|
||||||
|
if re.match(r"^\$\s*$", line):
|
||||||
|
_flush(cur, out, seen)
|
||||||
|
cur = None
|
||||||
|
continue
|
||||||
|
m = _RD_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["rd"] = m.group(1).strip()
|
||||||
|
continue
|
||||||
|
m = _DESC_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["description"] = (m.group(2) or "").strip()[:256]
|
||||||
|
continue
|
||||||
|
m = _AF_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["_afs"].add(m.group(1).lower())
|
||||||
|
continue
|
||||||
|
m = _RT_RE.match(line)
|
||||||
|
if m:
|
||||||
|
direction, value = m.group(1).lower(), m.group(2).strip()
|
||||||
|
if direction == "export":
|
||||||
|
cur["_rt_export"].add(value)
|
||||||
|
else:
|
||||||
|
cur["_rt_import"].add(value)
|
||||||
|
_flush(cur, out, seen)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
normalize_config_vrf.RULE_KEYS = RULE_KEYS
|
||||||
119
netx_api/biz_state/parsers/zte/evpn_mac.py
Normal file
119
netx_api/biz_state/parsers/zte/evpn_mac.py
Normal file
|
|
@ -0,0 +1,119 @@
|
||||||
|
"""ZTE: show bgp evpn mac."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ....lldp_shared import resolve_vendor_key
|
||||||
|
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
|
||||||
|
from ..common.pipeline import prefer_fsm
|
||||||
|
|
||||||
|
RULE_KEYS = ("zte_zxros_show_bgp_evpn_mac",)
|
||||||
|
|
||||||
|
_ROUTE_RE = re.compile(
|
||||||
|
r"^\s*(?P<flags>[*>isd]*)\s*"
|
||||||
|
r"(?P<net>\S+)\s+(?P<nh>\S+)\s+(?P<rest>.*)$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for r in rows:
|
||||||
|
net = row_get(r, "NETWORK", "network")
|
||||||
|
if not net or net.lower() == "network" or net in seen:
|
||||||
|
continue
|
||||||
|
seen.add(net)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"network": net[:256],
|
||||||
|
"next_hop": row_get(r, "NEXT_HOP", "next_hop")[:128],
|
||||||
|
"metric": row_get(r, "METRIC", "metric")[:32],
|
||||||
|
"loc_prf": row_get(r, "LOC_PRF", "loc_prf")[:32],
|
||||||
|
"rt_prf": row_get(r, "RT_PRF", "rt_prf")[:32],
|
||||||
|
"path": row_get(r, "PATH", "path")[:256],
|
||||||
|
"status_codes": "",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in str(raw_text or "").splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
low = line.strip().lower()
|
||||||
|
if low.startswith("network") or low.startswith("status") or low.startswith("origin"):
|
||||||
|
continue
|
||||||
|
if low.startswith("route distinguisher"):
|
||||||
|
continue
|
||||||
|
m = _ROUTE_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
net = m.group("net")
|
||||||
|
if "/" not in net and ":" not in net and not re.search(r"[0-9a-fA-F]{4}\.", net):
|
||||||
|
# skip non-NLRI-looking tokens
|
||||||
|
if not re.match(r"^\[", net):
|
||||||
|
continue
|
||||||
|
if net in seen:
|
||||||
|
continue
|
||||||
|
seen.add(net)
|
||||||
|
rest = m.group("rest").split()
|
||||||
|
metric = loc = rt = ""
|
||||||
|
path_parts: list[str] = []
|
||||||
|
for p in rest:
|
||||||
|
if not path_parts and re.fullmatch(r"\d+", p):
|
||||||
|
if not metric:
|
||||||
|
metric = p
|
||||||
|
elif not loc:
|
||||||
|
loc = p
|
||||||
|
elif not rt:
|
||||||
|
rt = p
|
||||||
|
else:
|
||||||
|
path_parts.append(p)
|
||||||
|
else:
|
||||||
|
path_parts.append(p)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"network": net[:256],
|
||||||
|
"next_hop": m.group("nh")[:128],
|
||||||
|
"metric": metric[:32],
|
||||||
|
"loc_prf": loc[:32],
|
||||||
|
"rt_prf": rt[:32],
|
||||||
|
"path": " ".join(path_parts)[:256],
|
||||||
|
"status_codes": (m.group("flags") or "").strip()[:16],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_evpn_mac(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = params
|
||||||
|
tables = dict(fsm_tables or {})
|
||||||
|
if not any(tables.get(k) for k in RULE_KEYS):
|
||||||
|
platform = resolve_cli_platform(
|
||||||
|
vendor=vendor,
|
||||||
|
device_type=device_type,
|
||||||
|
vendor_key=resolve_vendor_key(vendor, device_type),
|
||||||
|
)
|
||||||
|
cmd = str(command or "show bgp evpn mac").strip()
|
||||||
|
if platform and cmd:
|
||||||
|
tables = apply_rules(
|
||||||
|
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
|
||||||
|
)
|
||||||
|
return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
normalize_evpn_mac.RULE_KEYS = RULE_KEYS
|
||||||
195
netx_api/biz_state/parsers/zte/interface_detail.py
Normal file
195
netx_api/biz_state/parsers/zte/interface_detail.py
Normal file
|
|
@ -0,0 +1,195 @@
|
||||||
|
"""ZTE: filtered ``show interface | include …`` (rate / util / BW)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ....lldp_shared import resolve_vendor_key
|
||||||
|
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
|
||||||
|
from ..common.pipeline import prefer_fsm
|
||||||
|
|
||||||
|
RULE_KEYS = ("zte_zxros_show_interface",)
|
||||||
|
|
||||||
|
_IF_HDR_RE = re.compile(
|
||||||
|
r"^(?P<iface>\S+)\s+is\s+(?P<state>administratively\s+down|up|down)"
|
||||||
|
r"(?:,\s*ifindex:\s*(?P<ifindex>\d+))?\s*$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
_DESC_RE = re.compile(r"^\s*Description:\s*(?P<desc>.*?)\s*$", re.I)
|
||||||
|
_BW_RE = re.compile(r"^\s*BW\s+(?P<bw>.+?)\s*$", re.I)
|
||||||
|
_MTU_RE = re.compile(r"^\s*MTU\s+(?P<mtu>\d+)\s+bytes", re.I)
|
||||||
|
_MEDIA_RE = re.compile(r"^\s*The\s+port\s+is\s+(?P<media>\S+)", re.I)
|
||||||
|
_NEG_RE = re.compile(r"^\s*Negotiation\s+(?P<neg>\S+)", re.I)
|
||||||
|
_PERIOD_RE = re.compile(r"^\s*Rate\s+period\s*:\s*(?P<p>\d+)", re.I)
|
||||||
|
_IN_RE = re.compile(r"^\s*Input\s*:\s*(?P<bps>[\d.]+)\s*bit/s", re.I)
|
||||||
|
_OUT_RE = re.compile(r"^\s*Output\s*:\s*(?P<bps>[\d.]+)\s*bit/s", re.I)
|
||||||
|
_UTIL_RE = re.compile(
|
||||||
|
r"^\s*Intf\s+utilization:\s*input\s*(?P<inu>[\d.]+)%\s+output\s*(?P<outu>[\d.]+)%",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_row(iface: str) -> dict[str, Any]:
|
||||||
|
return {
|
||||||
|
"interface": iface[:128],
|
||||||
|
"admin": "",
|
||||||
|
"ifindex": "",
|
||||||
|
"description": "",
|
||||||
|
"port_media": "",
|
||||||
|
"negotiation": "",
|
||||||
|
"bw": "",
|
||||||
|
"mtu": "",
|
||||||
|
"rate_period": "",
|
||||||
|
"input_bps": "",
|
||||||
|
"output_bps": "",
|
||||||
|
"in_util": "",
|
||||||
|
"out_util": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_admin(state: str) -> str:
|
||||||
|
s = str(state or "").strip().lower()
|
||||||
|
# Guard against FSM greedily eating ", ifindex:"
|
||||||
|
if "," in s:
|
||||||
|
s = s.split(",", 1)[0].strip()
|
||||||
|
if "administratively" in s:
|
||||||
|
return "admin-down"
|
||||||
|
if s == "up":
|
||||||
|
return "up"
|
||||||
|
if s == "down":
|
||||||
|
return "down"
|
||||||
|
return s[:32]
|
||||||
|
|
||||||
|
|
||||||
|
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for r in rows:
|
||||||
|
iface = row_get(r, "IFNAME", "interface")
|
||||||
|
in_util = row_get(r, "IN_UTIL", "in_util")
|
||||||
|
# Incomplete FSM rows (no util yet) are filldown noise — skip
|
||||||
|
if not iface or not in_util or iface in seen:
|
||||||
|
continue
|
||||||
|
seen.add(iface)
|
||||||
|
admin_raw = row_get(r, "ADMIN_STATE", "ADMIN_OPER", "admin")
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"interface": iface[:128],
|
||||||
|
"admin": _normalize_admin(admin_raw)[:32],
|
||||||
|
"ifindex": row_get(r, "IFINDEX", "ifindex")[:32],
|
||||||
|
"description": row_get(r, "DESCRIPTION", "description")[:256],
|
||||||
|
"port_media": row_get(r, "PORT_MEDIA", "port_media")[:32],
|
||||||
|
"negotiation": row_get(r, "NEGOTIATION", "negotiation")[:32],
|
||||||
|
"bw": row_get(r, "BW_RAW", "bw")[:64],
|
||||||
|
"mtu": row_get(r, "MTU", "mtu")[:16],
|
||||||
|
"rate_period": row_get(r, "RATE_PERIOD", "rate_period")[:16],
|
||||||
|
"input_bps": row_get(r, "INPUT_BPS", "input_bps")[:32],
|
||||||
|
"output_bps": row_get(r, "OUTPUT_BPS", "output_bps")[:32],
|
||||||
|
"in_util": in_util[:16],
|
||||||
|
"out_util": row_get(r, "OUT_UTIL", "out_util")[:16],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
cur: dict[str, Any] | None = None
|
||||||
|
in_rates = False
|
||||||
|
saw_input = False
|
||||||
|
|
||||||
|
def _flush() -> None:
|
||||||
|
nonlocal cur
|
||||||
|
if cur and cur.get("interface"):
|
||||||
|
out.append(cur)
|
||||||
|
cur = None
|
||||||
|
|
||||||
|
for raw in str(raw_text or "").splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
m = _IF_HDR_RE.match(line.strip())
|
||||||
|
if m:
|
||||||
|
_flush()
|
||||||
|
cur = _empty_row(m.group("iface"))
|
||||||
|
cur["admin"] = _normalize_admin(m.group("state"))
|
||||||
|
cur["ifindex"] = (m.group("ifindex") or "")[:32]
|
||||||
|
in_rates = False
|
||||||
|
saw_input = False
|
||||||
|
continue
|
||||||
|
if not cur:
|
||||||
|
continue
|
||||||
|
m = _DESC_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["description"] = (m.group("desc") or "").strip()[:256]
|
||||||
|
continue
|
||||||
|
m = _MEDIA_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["port_media"] = m.group("media")[:32]
|
||||||
|
continue
|
||||||
|
m = _NEG_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["negotiation"] = m.group("neg")[:32]
|
||||||
|
continue
|
||||||
|
m = _BW_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["bw"] = m.group("bw").strip()[:64]
|
||||||
|
continue
|
||||||
|
m = _MTU_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["mtu"] = m.group("mtu")[:16]
|
||||||
|
continue
|
||||||
|
m = _PERIOD_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["rate_period"] = m.group("p")[:16]
|
||||||
|
in_rates = True
|
||||||
|
saw_input = False
|
||||||
|
continue
|
||||||
|
if in_rates and "Peak rate" in line:
|
||||||
|
continue
|
||||||
|
m = _IN_RE.match(line)
|
||||||
|
if m and in_rates and not saw_input:
|
||||||
|
cur["input_bps"] = m.group("bps")[:32]
|
||||||
|
saw_input = True
|
||||||
|
continue
|
||||||
|
m = _OUT_RE.match(line)
|
||||||
|
if m and in_rates and saw_input and not cur.get("output_bps"):
|
||||||
|
cur["output_bps"] = m.group("bps")[:32]
|
||||||
|
continue
|
||||||
|
m = _UTIL_RE.match(line)
|
||||||
|
if m:
|
||||||
|
cur["in_util"] = m.group("inu")[:16]
|
||||||
|
cur["out_util"] = m.group("outu")[:16]
|
||||||
|
in_rates = False
|
||||||
|
continue
|
||||||
|
_flush()
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_interface_detail(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = params
|
||||||
|
tables = dict(fsm_tables or {})
|
||||||
|
if not any(tables.get(k) for k in RULE_KEYS):
|
||||||
|
platform = resolve_cli_platform(
|
||||||
|
vendor=vendor,
|
||||||
|
device_type=device_type,
|
||||||
|
vendor_key=resolve_vendor_key(vendor, device_type),
|
||||||
|
)
|
||||||
|
cmd = str(command or "show interface").strip()
|
||||||
|
if platform and cmd:
|
||||||
|
tables = apply_rules(
|
||||||
|
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
|
||||||
|
)
|
||||||
|
return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
normalize_interface_detail.RULE_KEYS = RULE_KEYS
|
||||||
107
netx_api/biz_state/parsers/zte/ip_route.py
Normal file
107
netx_api/biz_state/parsers/zte/ip_route.py
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
"""ZTE: show ip forwarding route [vrf <vrf>]."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ....lldp_shared import resolve_vendor_key
|
||||||
|
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
|
||||||
|
from ..common.pipeline import prefer_fsm
|
||||||
|
from .bgp_peer import _detect_vrf
|
||||||
|
|
||||||
|
RULE_KEYS = ("zte_zxros_show_ip_forwarding_route",)
|
||||||
|
|
||||||
|
_ROUTE_RE = re.compile(
|
||||||
|
r"^\s*(?P<flags>[*>R]*)\s*"
|
||||||
|
r"(?P<dest>\d{1,3}(?:\.\d{1,3}){3}/\d+)\s+"
|
||||||
|
r"(?P<gw>\S+)\s+(?P<iface>\S+)\s+(?P<owner>\S+)\s+"
|
||||||
|
r"(?P<pri>\S+)\s+(?P<metric>\S+)\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_fsm_rows(rows: list[dict[str, Any]], *, vrf: str) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for r in rows:
|
||||||
|
dest = row_get(r, "DEST", "dest")
|
||||||
|
if not dest or dest.lower() == "dest" or dest in seen:
|
||||||
|
continue
|
||||||
|
seen.add(dest)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"vrf": vrf[:128],
|
||||||
|
"dest": dest[:64],
|
||||||
|
"gateway": row_get(r, "GATEWAY", "gateway")[:64],
|
||||||
|
"interface": row_get(r, "INTERFACE", "interface")[:128],
|
||||||
|
"owner": row_get(r, "OWNER", "owner")[:64],
|
||||||
|
"pri": row_get(r, "PRI", "pri")[:16],
|
||||||
|
"metric": row_get(r, "METRIC", "metric")[:32],
|
||||||
|
"flags": row_get(r, "FLAGS", "flags")[:16],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_parse(*, raw_text: str, vrf: str = "", **_kw: Any) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in str(raw_text or "").splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if not line.strip() or line.strip().lower().startswith("dest"):
|
||||||
|
continue
|
||||||
|
m = _ROUTE_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
dest = m.group("dest")
|
||||||
|
if dest in seen:
|
||||||
|
continue
|
||||||
|
seen.add(dest)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"vrf": vrf[:128],
|
||||||
|
"dest": dest[:64],
|
||||||
|
"gateway": m.group("gw")[:64],
|
||||||
|
"interface": m.group("iface")[:128],
|
||||||
|
"owner": m.group("owner")[:64],
|
||||||
|
"pri": m.group("pri")[:16],
|
||||||
|
"metric": m.group("metric")[:32],
|
||||||
|
"flags": (m.group("flags") or "").strip()[:16],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_ip_route(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
vrf = _detect_vrf(command, params)
|
||||||
|
tables = dict(fsm_tables or {})
|
||||||
|
if not any(tables.get(k) for k in RULE_KEYS):
|
||||||
|
platform = resolve_cli_platform(
|
||||||
|
vendor=vendor,
|
||||||
|
device_type=device_type,
|
||||||
|
vendor_key=resolve_vendor_key(vendor, device_type),
|
||||||
|
)
|
||||||
|
cmd = str(command or "show ip forwarding route").strip()
|
||||||
|
if platform and cmd:
|
||||||
|
tables = apply_rules(
|
||||||
|
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
|
||||||
|
)
|
||||||
|
|
||||||
|
def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return _map_fsm_rows(rows, vrf=vrf)
|
||||||
|
|
||||||
|
def _hand(*, raw_text: str, **kw: Any) -> list[dict[str, Any]]:
|
||||||
|
return _hand_parse(raw_text=raw_text, vrf=vrf, **kw)
|
||||||
|
|
||||||
|
return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
normalize_ip_route.RULE_KEYS = RULE_KEYS
|
||||||
128
netx_api/biz_state/parsers/zte/ipv6_route.py
Normal file
128
netx_api/biz_state/parsers/zte/ipv6_route.py
Normal file
|
|
@ -0,0 +1,128 @@
|
||||||
|
"""ZTE: show ipv6 forwarding route [vrf <vrf>]."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ....lldp_shared import resolve_vendor_key
|
||||||
|
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
|
||||||
|
from ..common.pipeline import prefer_fsm
|
||||||
|
from .bgp_peer import _detect_vrf
|
||||||
|
|
||||||
|
RULE_KEYS = ("zte_zxros_show_ipv6_forwarding_route",)
|
||||||
|
|
||||||
|
_DEST_RE = re.compile(
|
||||||
|
r"^\s*(?P<dest>[0-9A-Fa-f:]+/\d+)\s+(?P<owner>\S+)\s+(?P<pri>\S+)\s+"
|
||||||
|
r"(?P<metric>\S+)(?:\s+(?P<flags>\S+))?\s*$"
|
||||||
|
)
|
||||||
|
_NH_RE = re.compile(r"^\s+(?P<gw>[0-9A-Fa-f:]+|\S+),(?P<iface>\S+)\s*$")
|
||||||
|
|
||||||
|
|
||||||
|
def _map_fsm_rows(rows: list[dict[str, Any]], *, vrf: str) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
cur: dict[str, Any] | None = None
|
||||||
|
for r in rows:
|
||||||
|
dest = row_get(r, "DEST", "dest")
|
||||||
|
gw = row_get(r, "GATEWAY", "gateway")
|
||||||
|
iface = row_get(r, "INTERFACE", "interface")
|
||||||
|
if dest:
|
||||||
|
cur = {
|
||||||
|
"vrf": vrf[:128],
|
||||||
|
"dest": dest[:128],
|
||||||
|
"gateway": "",
|
||||||
|
"interface": "",
|
||||||
|
"owner": row_get(r, "OWNER", "owner")[:64],
|
||||||
|
"pri": row_get(r, "PRI", "pri")[:16],
|
||||||
|
"metric": row_get(r, "METRIC", "metric")[:32],
|
||||||
|
"flags": row_get(r, "FLAGS", "flags")[:16],
|
||||||
|
}
|
||||||
|
if gw or iface:
|
||||||
|
cur["gateway"] = gw[:128]
|
||||||
|
cur["interface"] = iface[:128]
|
||||||
|
key = (dest, gw)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
out.append(cur)
|
||||||
|
cur = None
|
||||||
|
continue
|
||||||
|
if cur and (gw or iface):
|
||||||
|
cur["gateway"] = gw[:128]
|
||||||
|
cur["interface"] = iface[:128]
|
||||||
|
key = (cur["dest"], gw)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
out.append(dict(cur))
|
||||||
|
cur = None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_parse(*, raw_text: str, vrf: str = "", **_kw: Any) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
pending: dict[str, Any] | None = None
|
||||||
|
for raw in str(raw_text or "").splitlines():
|
||||||
|
line = raw.rstrip()
|
||||||
|
if not line.strip():
|
||||||
|
continue
|
||||||
|
m = _DEST_RE.match(line)
|
||||||
|
if m:
|
||||||
|
pending = {
|
||||||
|
"vrf": vrf[:128],
|
||||||
|
"dest": m.group("dest")[:128],
|
||||||
|
"gateway": "",
|
||||||
|
"interface": "",
|
||||||
|
"owner": m.group("owner")[:64],
|
||||||
|
"pri": m.group("pri")[:16],
|
||||||
|
"metric": m.group("metric")[:32],
|
||||||
|
"flags": (m.group("flags") or "")[:16],
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
m2 = _NH_RE.match(line)
|
||||||
|
if m2 and pending:
|
||||||
|
gw = m2.group("gw")
|
||||||
|
key = (pending["dest"], gw)
|
||||||
|
if key not in seen:
|
||||||
|
seen.add(key)
|
||||||
|
row = dict(pending)
|
||||||
|
row["gateway"] = gw[:128]
|
||||||
|
row["interface"] = m2.group("iface")[:128]
|
||||||
|
out.append(row)
|
||||||
|
pending = None
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_ipv6_route(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
vrf = _detect_vrf(command, params)
|
||||||
|
tables = dict(fsm_tables or {})
|
||||||
|
if not any(tables.get(k) for k in RULE_KEYS):
|
||||||
|
platform = resolve_cli_platform(
|
||||||
|
vendor=vendor,
|
||||||
|
device_type=device_type,
|
||||||
|
vendor_key=resolve_vendor_key(vendor, device_type),
|
||||||
|
)
|
||||||
|
cmd = str(command or "show ipv6 forwarding route").strip()
|
||||||
|
if platform and cmd:
|
||||||
|
tables = apply_rules(
|
||||||
|
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
|
||||||
|
)
|
||||||
|
|
||||||
|
def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return _map_fsm_rows(rows, vrf=vrf)
|
||||||
|
|
||||||
|
def _hand(*, raw_text: str, **kw: Any) -> list[dict[str, Any]]:
|
||||||
|
return _hand_parse(raw_text=raw_text, vrf=vrf, **kw)
|
||||||
|
|
||||||
|
return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
normalize_ipv6_route.RULE_KEYS = RULE_KEYS
|
||||||
95
netx_api/biz_state/parsers/zte/l2vpn_mac.py
Normal file
95
netx_api/biz_state/parsers/zte/l2vpn_mac.py
Normal file
|
|
@ -0,0 +1,95 @@
|
||||||
|
"""ZTE: show mac l2vpn."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ....lldp_shared import resolve_vendor_key
|
||||||
|
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
|
||||||
|
from ..common.pipeline import prefer_fsm
|
||||||
|
|
||||||
|
RULE_KEYS = ("zte_zxros_show_mac_l2vpn",)
|
||||||
|
|
||||||
|
_MAC_RE = re.compile(
|
||||||
|
r"^(?P<mac>[0-9a-fA-F]{4}\.[0-9a-fA-F]{4}\.[0-9a-fA-F]{4})\s+"
|
||||||
|
r"(?P<vpn>\S+)\s+(?P<vlan>\S+)\s+(?P<out>.+?)\s+(?P<attr>\S+)\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for r in rows:
|
||||||
|
mac = row_get(r, "MAC", "mac")
|
||||||
|
vpn = row_get(r, "VPN", "vpn")
|
||||||
|
if not mac or mac.lower() == "mac":
|
||||||
|
continue
|
||||||
|
key = (mac, vpn)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"mac": mac[:64],
|
||||||
|
"vpn": vpn[:128],
|
||||||
|
"vlan": row_get(r, "VLAN", "vlan")[:32],
|
||||||
|
"outgoing": row_get(r, "OUTGOING", "outgoing")[:256],
|
||||||
|
"attribute": row_get(r, "ATTRIBUTE", "attribute")[:64],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str]] = set()
|
||||||
|
for raw in str(raw_text or "").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.lower().startswith("mac "):
|
||||||
|
continue
|
||||||
|
m = _MAC_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
key = (m.group("mac"), m.group("vpn"))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"mac": m.group("mac")[:64],
|
||||||
|
"vpn": m.group("vpn")[:128],
|
||||||
|
"vlan": m.group("vlan")[:32],
|
||||||
|
"outgoing": m.group("out").strip()[:256],
|
||||||
|
"attribute": m.group("attr")[:64],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_l2vpn_mac(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = params
|
||||||
|
tables = dict(fsm_tables or {})
|
||||||
|
if not any(tables.get(k) for k in RULE_KEYS):
|
||||||
|
platform = resolve_cli_platform(
|
||||||
|
vendor=vendor,
|
||||||
|
device_type=device_type,
|
||||||
|
vendor_key=resolve_vendor_key(vendor, device_type),
|
||||||
|
)
|
||||||
|
cmd = str(command or "show mac l2vpn").strip()
|
||||||
|
if platform and cmd:
|
||||||
|
tables = apply_rules(
|
||||||
|
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
|
||||||
|
)
|
||||||
|
return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
normalize_l2vpn_mac.RULE_KEYS = RULE_KEYS
|
||||||
106
netx_api/biz_state/parsers/zte/l2vpn_pw.py
Normal file
106
netx_api/biz_state/parsers/zte/l2vpn_pw.py
Normal file
|
|
@ -0,0 +1,106 @@
|
||||||
|
"""ZTE: show l2vpn forwardinfo (PW state)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ....lldp_shared import resolve_vendor_key
|
||||||
|
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
|
||||||
|
from ..common.pipeline import prefer_fsm
|
||||||
|
|
||||||
|
RULE_KEYS = ("zte_zxros_show_l2vpn_forwardinfo",)
|
||||||
|
|
||||||
|
_PW_RE = re.compile(
|
||||||
|
r"^(?P<name>\S+)\s+(?P<peer>\S+)\s+(?P<fec>\S+)\s+(?P<pwt>\S+)\s+"
|
||||||
|
r"(?:(?P<mode>[A-Za-z$]+)\s+)?"
|
||||||
|
r"(?P<state>UP|DOWN)\s+(?P<ll>\S+)\s+(?P<rl>\S+)\s+(?P<owner>\S+)\s*$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for r in rows:
|
||||||
|
name = row_get(r, "PW_NAME", "pw_name")
|
||||||
|
if not name or name.lower() == "pwname" or name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(name)
|
||||||
|
state = row_get(r, "STATE", "state")
|
||||||
|
# FSM may glue mode into pw_type
|
||||||
|
pwt = row_get(r, "PW_TYPE", "pw_type")
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"pw_name": name[:128],
|
||||||
|
"peer": row_get(r, "PEER", "peer")[:64],
|
||||||
|
"fec": row_get(r, "FEC", "fec")[:32],
|
||||||
|
"pw_type": pwt[:64],
|
||||||
|
"state": state[:32],
|
||||||
|
"local_label": row_get(r, "LLABEL", "local_label")[:32],
|
||||||
|
"remote_label": row_get(r, "RLABEL", "remote_label")[:32],
|
||||||
|
"vpn_owner": row_get(r, "VPN_OWNER", "vpn_owner")[:256],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in str(raw_text or "").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.lower().startswith("pwname"):
|
||||||
|
continue
|
||||||
|
m = _PW_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
name = m.group("name")
|
||||||
|
if name in seen:
|
||||||
|
continue
|
||||||
|
seen.add(name)
|
||||||
|
mode = (m.group("mode") or "").strip()
|
||||||
|
pwt = m.group("pwt")
|
||||||
|
if mode:
|
||||||
|
pwt = f"{pwt} {mode}"
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"pw_name": name[:128],
|
||||||
|
"peer": m.group("peer")[:64],
|
||||||
|
"fec": m.group("fec")[:32],
|
||||||
|
"pw_type": pwt[:64],
|
||||||
|
"state": m.group("state")[:32],
|
||||||
|
"local_label": m.group("ll")[:32],
|
||||||
|
"remote_label": m.group("rl")[:32],
|
||||||
|
"vpn_owner": m.group("owner")[:256],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_l2vpn_pw(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = params
|
||||||
|
tables = dict(fsm_tables or {})
|
||||||
|
if not any(tables.get(k) for k in RULE_KEYS):
|
||||||
|
platform = resolve_cli_platform(
|
||||||
|
vendor=vendor,
|
||||||
|
device_type=device_type,
|
||||||
|
vendor_key=resolve_vendor_key(vendor, device_type),
|
||||||
|
)
|
||||||
|
cmd = str(command or "show l2vpn forwardinfo").strip()
|
||||||
|
if platform and cmd:
|
||||||
|
tables = apply_rules(
|
||||||
|
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
|
||||||
|
)
|
||||||
|
return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
normalize_l2vpn_pw.RULE_KEYS = RULE_KEYS
|
||||||
93
netx_api/biz_state/parsers/zte/optical_brief.py
Normal file
93
netx_api/biz_state/parsers/zte/optical_brief.py
Normal file
|
|
@ -0,0 +1,93 @@
|
||||||
|
"""ZTE: show opticalinfo brief."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ....lldp_shared import resolve_vendor_key
|
||||||
|
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
|
||||||
|
from ..common.pipeline import prefer_fsm
|
||||||
|
|
||||||
|
RULE_KEYS = ("zte_zxros_show_opticalinfo_brief",)
|
||||||
|
|
||||||
|
_OPT_RE = re.compile(
|
||||||
|
r"^(?P<iface>\S+)\s+(?P<otype>\S+)\s+(?P<wave>\S+)\s+"
|
||||||
|
r"(?P<rx>\S+)\s+(?P<tx>\S+)\s+(?P<status>\S+)",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for r in rows:
|
||||||
|
iface = row_get(r, "INTERFACE", "interface")
|
||||||
|
if not iface or iface.lower() == "interface" or iface in seen:
|
||||||
|
continue
|
||||||
|
seen.add(iface)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"interface": iface[:128],
|
||||||
|
"optic_type": row_get(r, "OPTIC_TYPE", "optic_type")[:64],
|
||||||
|
"wavelength": row_get(r, "WAVELENGTH", "wavelength")[:32],
|
||||||
|
"rx_power": row_get(r, "RX_POWER", "rx_power")[:64],
|
||||||
|
"tx_power": row_get(r, "TX_POWER", "tx_power")[:64],
|
||||||
|
"status": row_get(r, "STATUS", "status")[:32],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[str] = set()
|
||||||
|
for raw in str(raw_text or "").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.lower().startswith("interface"):
|
||||||
|
continue
|
||||||
|
m = _OPT_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
iface = m.group("iface")
|
||||||
|
if iface in seen:
|
||||||
|
continue
|
||||||
|
seen.add(iface)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"interface": iface[:128],
|
||||||
|
"optic_type": m.group("otype")[:64],
|
||||||
|
"wavelength": m.group("wave")[:32],
|
||||||
|
"rx_power": m.group("rx")[:64],
|
||||||
|
"tx_power": m.group("tx")[:64],
|
||||||
|
"status": m.group("status")[:32],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_optical_brief(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = params
|
||||||
|
tables = dict(fsm_tables or {})
|
||||||
|
if not any(tables.get(k) for k in RULE_KEYS):
|
||||||
|
platform = resolve_cli_platform(
|
||||||
|
vendor=vendor,
|
||||||
|
device_type=device_type,
|
||||||
|
vendor_key=resolve_vendor_key(vendor, device_type),
|
||||||
|
)
|
||||||
|
cmd = str(command or "show opticalinfo brief").strip()
|
||||||
|
if platform and cmd:
|
||||||
|
tables = apply_rules(
|
||||||
|
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
|
||||||
|
)
|
||||||
|
return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
normalize_optical_brief.RULE_KEYS = RULE_KEYS
|
||||||
108
netx_api/biz_state/parsers/zte/ospf_neighbor.py
Normal file
108
netx_api/biz_state/parsers/zte/ospf_neighbor.py
Normal file
|
|
@ -0,0 +1,108 @@
|
||||||
|
"""ZTE: show ip ospf neighbor."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ....lldp_shared import resolve_vendor_key
|
||||||
|
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
|
||||||
|
from ..common.pipeline import prefer_fsm
|
||||||
|
|
||||||
|
RULE_KEYS = ("zte_zxros_show_ospf_neighbor",)
|
||||||
|
|
||||||
|
_PROC_RE = re.compile(r"Process\s+ID\s+(\d+)", re.I)
|
||||||
|
_NEI_RE = re.compile(
|
||||||
|
r"^(?P<nid>\d{1,3}(?:\.\d{1,3}){3})\s+(?P<pri>\S+)\s+(?P<state>\S+)\s+"
|
||||||
|
r"(?P<dead>\S+)\s+(?P<addr>\S+)\s+(?P<iface>\S+)\s*$"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _map_fsm_rows(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str, str]] = set()
|
||||||
|
for r in rows:
|
||||||
|
nid = row_get(r, "NEIGHBOR_ID", "neighbor_id")
|
||||||
|
iface = row_get(r, "INTERFACE", "interface")
|
||||||
|
proc = row_get(r, "PROCESS_ID", "process_id") or "0"
|
||||||
|
if not nid or not iface:
|
||||||
|
continue
|
||||||
|
key = (proc, nid, iface)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"process_id": proc[:32],
|
||||||
|
"neighbor_id": nid[:64],
|
||||||
|
"pri": row_get(r, "PRI", "pri")[:16],
|
||||||
|
"state": row_get(r, "STATE", "state")[:64],
|
||||||
|
"dead_time": row_get(r, "DEAD_TIME", "dead_time")[:32],
|
||||||
|
"address": row_get(r, "ADDRESS", "address")[:64],
|
||||||
|
"interface": iface[:128],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_parse(*, raw_text: str, **_kw: Any) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str, str]] = set()
|
||||||
|
proc = "0"
|
||||||
|
for raw in str(raw_text or "").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line:
|
||||||
|
continue
|
||||||
|
pm = _PROC_RE.search(line)
|
||||||
|
if pm:
|
||||||
|
proc = pm.group(1)
|
||||||
|
continue
|
||||||
|
if line.lower().startswith("neighbor id"):
|
||||||
|
continue
|
||||||
|
m = _NEI_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
key = (proc, m.group("nid"), m.group("iface"))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"process_id": proc[:32],
|
||||||
|
"neighbor_id": m.group("nid")[:64],
|
||||||
|
"pri": m.group("pri")[:16],
|
||||||
|
"state": m.group("state")[:64],
|
||||||
|
"dead_time": m.group("dead")[:32],
|
||||||
|
"address": m.group("addr")[:64],
|
||||||
|
"interface": m.group("iface")[:128],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_ospf_neighbor(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
_ = params
|
||||||
|
tables = dict(fsm_tables or {})
|
||||||
|
if not any(tables.get(k) for k in RULE_KEYS):
|
||||||
|
platform = resolve_cli_platform(
|
||||||
|
vendor=vendor,
|
||||||
|
device_type=device_type,
|
||||||
|
vendor_key=resolve_vendor_key(vendor, device_type),
|
||||||
|
)
|
||||||
|
cmd = str(command or "show ip ospf neighbor").strip()
|
||||||
|
if platform and cmd:
|
||||||
|
tables = apply_rules(
|
||||||
|
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
|
||||||
|
)
|
||||||
|
return prefer_fsm(tables, RULE_KEYS, _map_fsm_rows, _hand_parse, raw_text=raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
normalize_ospf_neighbor.RULE_KEYS = RULE_KEYS
|
||||||
122
netx_api/biz_state/parsers/zte/vrrp.py
Normal file
122
netx_api/biz_state/parsers/zte/vrrp.py
Normal file
|
|
@ -0,0 +1,122 @@
|
||||||
|
"""ZTE: show vrrp {ipv4|ipv6} brief."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from typing import Any, Mapping
|
||||||
|
|
||||||
|
from ....lldp_shared import resolve_vendor_key
|
||||||
|
from ....ntc_parse import apply_rules, resolve_cli_platform, row_get
|
||||||
|
from ..common.pipeline import prefer_fsm
|
||||||
|
|
||||||
|
RULE_KEYS = ("zte_zxros_show_vrrp_brief",)
|
||||||
|
|
||||||
|
_VRRP_RE = re.compile(
|
||||||
|
r"^(?P<iface>\S+)\s+(?P<vrid>\d+)\s+(?P<pri>\d+)\s+(?P<time>\d+)\s+"
|
||||||
|
r"(?:(?P<flags>[APL\s]+)\s+)?"
|
||||||
|
r"(?P<state>Master|Backup|Init)\s+"
|
||||||
|
r"(?P<master>\S+)\s+(?P<vrouter>\S+)\s*$",
|
||||||
|
re.I,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _detect_af(command: str, params: dict[str, str] | None) -> str:
|
||||||
|
if params and params.get("af"):
|
||||||
|
return str(params.get("af") or "").strip().lower()
|
||||||
|
low = str(command or "").lower()
|
||||||
|
if "ipv6" in low:
|
||||||
|
return "ipv6"
|
||||||
|
return "ipv4"
|
||||||
|
|
||||||
|
|
||||||
|
def _map_fsm_rows(rows: list[dict[str, Any]], *, af: str) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str, str]] = set()
|
||||||
|
for r in rows:
|
||||||
|
iface = row_get(r, "INTERFACE", "interface")
|
||||||
|
vrid = row_get(r, "VR_ID", "vr_id")
|
||||||
|
if not iface or not vrid:
|
||||||
|
continue
|
||||||
|
key = (af, iface, vrid)
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"af": af[:16],
|
||||||
|
"interface": iface[:128],
|
||||||
|
"vr_id": vrid[:32],
|
||||||
|
"priority": row_get(r, "PRIORITY", "priority")[:16],
|
||||||
|
"time": row_get(r, "TIME", "time")[:16],
|
||||||
|
"flags": row_get(r, "FLAGS", "flags")[:16],
|
||||||
|
"state": row_get(r, "STATE", "state")[:32],
|
||||||
|
"master_addr": row_get(r, "MASTER_ADDR", "master_addr")[:128],
|
||||||
|
"vrouter_addr": row_get(r, "VROUTER_ADDR", "vrouter_addr")[:128],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hand_parse(*, raw_text: str, af: str = "ipv4", **_kw: Any) -> list[dict[str, Any]]:
|
||||||
|
out: list[dict[str, Any]] = []
|
||||||
|
seen: set[tuple[str, str, str]] = set()
|
||||||
|
for raw in str(raw_text or "").splitlines():
|
||||||
|
line = raw.strip()
|
||||||
|
if not line or line.lower().startswith("interface"):
|
||||||
|
continue
|
||||||
|
m = _VRRP_RE.match(line)
|
||||||
|
if not m:
|
||||||
|
continue
|
||||||
|
key = (af, m.group("iface"), m.group("vrid"))
|
||||||
|
if key in seen:
|
||||||
|
continue
|
||||||
|
seen.add(key)
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"af": af[:16],
|
||||||
|
"interface": m.group("iface")[:128],
|
||||||
|
"vr_id": m.group("vrid")[:32],
|
||||||
|
"priority": m.group("pri")[:16],
|
||||||
|
"time": m.group("time")[:16],
|
||||||
|
"flags": (m.group("flags") or "").strip()[:16],
|
||||||
|
"state": m.group("state")[:32],
|
||||||
|
"master_addr": m.group("master")[:128],
|
||||||
|
"vrouter_addr": m.group("vrouter")[:128],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_vrrp(
|
||||||
|
*,
|
||||||
|
raw_text: str,
|
||||||
|
fsm_tables: Mapping[str, list[dict[str, Any]]] | None = None,
|
||||||
|
vendor: str = "",
|
||||||
|
device_type: str = "",
|
||||||
|
command: str = "",
|
||||||
|
params: dict[str, str] | None = None,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
af = _detect_af(command, params)
|
||||||
|
tables = dict(fsm_tables or {})
|
||||||
|
if not any(tables.get(k) for k in RULE_KEYS):
|
||||||
|
platform = resolve_cli_platform(
|
||||||
|
vendor=vendor,
|
||||||
|
device_type=device_type,
|
||||||
|
vendor_key=resolve_vendor_key(vendor, device_type),
|
||||||
|
)
|
||||||
|
cmd = str(command or "show vrrp ipv4 brief").strip()
|
||||||
|
if platform and cmd:
|
||||||
|
tables = apply_rules(
|
||||||
|
platform=platform, text=raw_text, rule_keys=RULE_KEYS, command=cmd
|
||||||
|
)
|
||||||
|
|
||||||
|
def _map(rows: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
return _map_fsm_rows(rows, af=af)
|
||||||
|
|
||||||
|
def _hand(*, raw_text: str, **kw: Any) -> list[dict[str, Any]]:
|
||||||
|
return _hand_parse(raw_text=raw_text, af=af, **kw)
|
||||||
|
|
||||||
|
return prefer_fsm(tables, RULE_KEYS, _map, _hand, raw_text=raw_text)
|
||||||
|
|
||||||
|
|
||||||
|
normalize_vrrp.RULE_KEYS = RULE_KEYS
|
||||||
|
|
@ -155,7 +155,11 @@ def _vrf_profiles() -> list[ParseProfile]:
|
||||||
r"(?i)^\s*display\s+ip\s+vpn-instance\s*$",
|
r"(?i)^\s*display\s+ip\s+vpn-instance\s*$",
|
||||||
"display ip vpn-instance",
|
"display ip vpn-instance",
|
||||||
),
|
),
|
||||||
"zte": ("show ip vrf", r"(?i)^\s*show\s+ip\s+vrf\s*$", "show ip vrf"),
|
"zte": (
|
||||||
|
"show ip vrf | one-line",
|
||||||
|
r"(?i)^\s*show\s+ip\s+vrf(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
"show ip vrf",
|
||||||
|
),
|
||||||
}
|
}
|
||||||
collect_cmds: dict[str, tuple[str, str]] = {
|
collect_cmds: dict[str, tuple[str, str]] = {
|
||||||
"cisco": (
|
"cisco": (
|
||||||
|
|
@ -194,6 +198,7 @@ def _vrf_profiles() -> list[ParseProfile]:
|
||||||
FieldDef("vrf_name", length=128, indexed=True, is_key=True, display_name="VRF"),
|
FieldDef("vrf_name", length=128, indexed=True, is_key=True, display_name="VRF"),
|
||||||
FieldDef("rd", length=64, role="meta", display_name="RD"),
|
FieldDef("rd", length=64, role="meta", display_name="RD"),
|
||||||
FieldDef("protocols", length=64, role="meta", display_name="协议"),
|
FieldDef("protocols", length=64, role="meta", display_name="协议"),
|
||||||
|
FieldDef("vrf_id", length=32, role="meta", display_name="VRF ID"),
|
||||||
],
|
],
|
||||||
tags=["vrf", "discover"],
|
tags=["vrf", "discover"],
|
||||||
sort_order=order,
|
sort_order=order,
|
||||||
|
|
@ -266,6 +271,23 @@ _IFACE_BRIEF_FIELDS: list[FieldDef] = [
|
||||||
FieldDef("description", length=256, role="meta", display_name="描述"),
|
FieldDef("description", length=256, role="meta", display_name="描述"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_IFACE_DETAIL_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
|
||||||
|
FieldDef("admin", length=32, role="state", display_name="Admin"),
|
||||||
|
# Rates churn between collects — display only, never default-compare.
|
||||||
|
FieldDef("input_bps", length=32, role="counter", display_name="Input bps"),
|
||||||
|
FieldDef("output_bps", length=32, role="counter", display_name="Output bps"),
|
||||||
|
FieldDef("in_util", length=16, role="counter", display_name="In Util%"),
|
||||||
|
FieldDef("out_util", length=16, role="counter", display_name="Out Util%"),
|
||||||
|
FieldDef("bw", length=64, role="meta", display_name="BW"),
|
||||||
|
FieldDef("mtu", length=16, role="meta", display_name="MTU"),
|
||||||
|
FieldDef("ifindex", length=32, role="meta", display_name="ifindex"),
|
||||||
|
FieldDef("description", length=256, role="meta", display_name="描述"),
|
||||||
|
FieldDef("port_media", length=32, role="meta", display_name="Media"),
|
||||||
|
FieldDef("negotiation", length=32, role="meta", display_name="Negotiation"),
|
||||||
|
FieldDef("rate_period", length=16, role="meta", display_name="Rate period"),
|
||||||
|
]
|
||||||
|
|
||||||
_ARP_FIELDS: list[FieldDef] = [
|
_ARP_FIELDS: list[FieldDef] = [
|
||||||
FieldDef("ip", length=64, indexed=True, is_key=True, display_name="IP"),
|
FieldDef("ip", length=64, indexed=True, is_key=True, display_name="IP"),
|
||||||
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
|
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
|
||||||
|
|
@ -294,16 +316,228 @@ _ND6_FIELDS: list[FieldDef] = [
|
||||||
|
|
||||||
_BGP_PEER_FIELDS: list[FieldDef] = [
|
_BGP_PEER_FIELDS: list[FieldDef] = [
|
||||||
FieldDef("afi", length=32, indexed=True, is_key=True, display_name="AFI", from_command_param=True),
|
FieldDef("afi", length=32, indexed=True, is_key=True, display_name="AFI", from_command_param=True),
|
||||||
FieldDef("neighbor", length=64, indexed=True, is_key=True, display_name="Neighbor"),
|
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True),
|
||||||
|
FieldDef("neighbor", length=128, indexed=True, is_key=True, display_name="Neighbor"),
|
||||||
FieldDef("as_num", length=16, role="state", display_name="AS"),
|
FieldDef("as_num", length=16, role="state", display_name="AS"),
|
||||||
FieldDef("state", length=64, role="state", display_name="State"),
|
FieldDef("state", length=64, role="state", display_name="State"),
|
||||||
FieldDef("pfx_rcd", length=32, role="state", display_name="PfxRcd"),
|
# Prefix count churns between collects — display only for cutover compare.
|
||||||
|
FieldDef("pfx_rcd", length=32, role="counter", display_name="PfxRcd"),
|
||||||
FieldDef("ver", length=8, role="meta", display_name="Ver"),
|
FieldDef("ver", length=8, role="meta", display_name="Ver"),
|
||||||
FieldDef("msg_rcvd", length=32, role="meta", display_name="MsgRcvd"),
|
FieldDef("msg_rcvd", length=32, role="meta", display_name="MsgRcvd"),
|
||||||
FieldDef("msg_send", length=32, role="meta", display_name="MsgSend"),
|
FieldDef("msg_send", length=32, role="meta", display_name="MsgSend"),
|
||||||
FieldDef("up_down", length=32, role="meta", display_name="Up/Down"),
|
FieldDef("up_down", length=32, role="meta", display_name="Up/Down"),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
_BGP_VRF_PLACEHOLDER = PlaceholderDef(
|
||||||
|
name="vrf",
|
||||||
|
schema_field="vrf",
|
||||||
|
required=True,
|
||||||
|
bind_mode="discover_select",
|
||||||
|
discover_profile_id="zte.vrf_list",
|
||||||
|
discover_value_field="vrf_name",
|
||||||
|
discover_label_field="vrf_name",
|
||||||
|
)
|
||||||
|
|
||||||
|
_BGP_NEIGHBOR_PLACEHOLDER = PlaceholderDef(
|
||||||
|
name="neighbor",
|
||||||
|
schema_field="neighbor",
|
||||||
|
required=True,
|
||||||
|
bind_mode="manual_text",
|
||||||
|
)
|
||||||
|
|
||||||
|
_OSPF_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("process_id", length=32, indexed=True, is_key=True, display_name="Process ID"),
|
||||||
|
FieldDef("neighbor_id", length=64, indexed=True, is_key=True, display_name="Neighbor ID"),
|
||||||
|
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
|
||||||
|
FieldDef("state", length=64, role="state", display_name="State"),
|
||||||
|
FieldDef("address", length=64, role="state", display_name="Address"),
|
||||||
|
FieldDef("dead_time", length=32, role="meta", display_name="DeadTime"),
|
||||||
|
FieldDef("pri", length=16, role="meta", display_name="Pri"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_VRRP_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("af", length=16, indexed=True, is_key=True, display_name="AF", from_command_param=True),
|
||||||
|
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
|
||||||
|
FieldDef("vr_id", length=32, indexed=True, is_key=True, display_name="VR ID"),
|
||||||
|
FieldDef("state", length=32, role="state", display_name="State"),
|
||||||
|
FieldDef("priority", length=16, role="state", display_name="Priority"),
|
||||||
|
FieldDef("master_addr", length=128, role="state", display_name="Master"),
|
||||||
|
FieldDef("vrouter_addr", length=128, role="state", display_name="VRouter"),
|
||||||
|
FieldDef("time", length=16, role="meta", display_name="Time"),
|
||||||
|
FieldDef("flags", length=16, role="meta", display_name="Flags"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_OPTICAL_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
|
||||||
|
FieldDef("optic_type", length=64, role="meta", display_name="Type"),
|
||||||
|
FieldDef("wavelength", length=32, role="meta", display_name="Wavelength"),
|
||||||
|
# Optical power jitters — display only; compare status for cutover.
|
||||||
|
FieldDef("rx_power", length=64, role="counter", display_name="RxPower"),
|
||||||
|
FieldDef("tx_power", length=64, role="counter", display_name="TxPower"),
|
||||||
|
FieldDef("status", length=32, role="state", display_name="Status"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_BGP_ROUTE_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("afi", length=32, indexed=True, is_key=True, display_name="AFI", from_command_param=True),
|
||||||
|
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True),
|
||||||
|
FieldDef("neighbor", length=128, indexed=True, is_key=True, display_name="Neighbor", from_command_param=True),
|
||||||
|
FieldDef("direction", length=8, indexed=True, is_key=True, display_name="Dir", from_command_param=True),
|
||||||
|
FieldDef("network", length=128, indexed=True, is_key=True, display_name="Network"),
|
||||||
|
FieldDef("next_hop", length=128, role="state", display_name="NextHop"),
|
||||||
|
FieldDef("metric", length=32, role="meta", display_name="Metric"),
|
||||||
|
FieldDef("loc_prf", length=32, role="meta", display_name="LocPrf"),
|
||||||
|
FieldDef("tag", length=32, role="meta", display_name="Tag"),
|
||||||
|
FieldDef("path", length=256, role="state", display_name="Path"),
|
||||||
|
FieldDef("status_codes", length=16, role="meta", display_name="Codes"),
|
||||||
|
FieldDef("as_num", length=16, role="state", display_name="AS"),
|
||||||
|
FieldDef("state", length=64, role="state", display_name="PeerState"),
|
||||||
|
FieldDef("pfx_rcd", length=32, role="meta", display_name="PfxRcd"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_IP_ROUTE_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True),
|
||||||
|
FieldDef("dest", length=64, indexed=True, is_key=True, display_name="Dest"),
|
||||||
|
FieldDef("gateway", length=64, indexed=True, is_key=True, display_name="Gateway"),
|
||||||
|
FieldDef("interface", length=128, is_key=True, is_interface=True, display_name="接口"),
|
||||||
|
FieldDef("owner", length=64, role="state", display_name="Owner"),
|
||||||
|
FieldDef("pri", length=16, role="meta", display_name="Pri"),
|
||||||
|
FieldDef("metric", length=32, role="meta", display_name="Metric"),
|
||||||
|
FieldDef("flags", length=16, role="meta", display_name="Flags"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_IPV6_ROUTE_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF", from_command_param=True),
|
||||||
|
FieldDef("dest", length=128, indexed=True, is_key=True, display_name="Dest"),
|
||||||
|
FieldDef("gateway", length=128, indexed=True, is_key=True, display_name="Gateway"),
|
||||||
|
FieldDef("interface", length=128, is_key=True, is_interface=True, display_name="接口"),
|
||||||
|
FieldDef("owner", length=64, role="state", display_name="Owner"),
|
||||||
|
FieldDef("pri", length=16, role="meta", display_name="Pri"),
|
||||||
|
FieldDef("metric", length=32, role="meta", display_name="Metric"),
|
||||||
|
FieldDef("flags", length=16, role="meta", display_name="Flags"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_L2VPN_PW_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("pw_name", length=128, indexed=True, is_key=True, display_name="PW"),
|
||||||
|
FieldDef("peer", length=64, indexed=True, is_key=True, display_name="Peer"),
|
||||||
|
FieldDef("state", length=32, role="state", display_name="State"),
|
||||||
|
FieldDef("fec", length=32, role="meta", display_name="FEC"),
|
||||||
|
FieldDef("pw_type", length=64, role="meta", display_name="PWType"),
|
||||||
|
FieldDef("local_label", length=32, role="meta", display_name="Llabel"),
|
||||||
|
FieldDef("remote_label", length=32, role="meta", display_name="Rlabel"),
|
||||||
|
FieldDef("vpn_owner", length=256, role="meta", display_name="VPNOwner"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_L2VPN_MAC_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("mac", length=64, indexed=True, is_key=True, display_name="MAC"),
|
||||||
|
FieldDef("vpn", length=128, indexed=True, is_key=True, display_name="VPN"),
|
||||||
|
FieldDef("vlan", length=32, role="meta", display_name="VLAN"),
|
||||||
|
FieldDef("outgoing", length=256, role="state", display_name="Outgoing"),
|
||||||
|
FieldDef("attribute", length=64, role="meta", display_name="Attribute"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_EVPN_MAC_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("network", length=256, indexed=True, is_key=True, display_name="Network"),
|
||||||
|
FieldDef("next_hop", length=128, role="state", display_name="NextHop"),
|
||||||
|
FieldDef("path", length=256, role="state", display_name="Path"),
|
||||||
|
FieldDef("metric", length=32, role="meta", display_name="Metric"),
|
||||||
|
FieldDef("loc_prf", length=32, role="meta", display_name="LocPrf"),
|
||||||
|
FieldDef("rt_prf", length=32, role="meta", display_name="RtPrf"),
|
||||||
|
FieldDef("status_codes", length=16, role="meta", display_name="Codes"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_CONFIG_VRF_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("vrf_name", length=128, indexed=True, is_key=True, display_name="VRF"),
|
||||||
|
FieldDef("rd", length=64, role="state", display_name="RD"),
|
||||||
|
FieldDef("address_families", length=64, role="state", display_name="AF"),
|
||||||
|
FieldDef("rt_export", length=512, role="state", display_name="RT Export"),
|
||||||
|
FieldDef("rt_import", length=512, role="state", display_name="RT Import"),
|
||||||
|
FieldDef("description", length=256, role="meta", display_name="描述"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_CONFIG_IFACE_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
|
||||||
|
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF"),
|
||||||
|
FieldDef("admin", length=16, role="state", display_name="Admin"),
|
||||||
|
FieldDef("ip_address", length=256, role="state", display_name="IPv4"),
|
||||||
|
FieldDef("ipv6_address", length=256, role="state", display_name="IPv6"),
|
||||||
|
FieldDef("mtu", length=16, role="meta", display_name="MTU"),
|
||||||
|
FieldDef("description", length=256, role="meta", display_name="描述"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_CONFIG_BGP_PEER_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("afi", length=32, indexed=True, is_key=True, display_name="AFI"),
|
||||||
|
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF"),
|
||||||
|
FieldDef("neighbor", length=128, indexed=True, is_key=True, display_name="Neighbor"),
|
||||||
|
FieldDef("peer_group", length=64, indexed=True, is_key=True, display_name="Group"),
|
||||||
|
FieldDef("remote_as", length=16, role="state", display_name="Remote AS"),
|
||||||
|
FieldDef("activate", length=16, role="state", display_name="Activate"),
|
||||||
|
FieldDef("update_source", length=64, role="meta", display_name="Update-Source"),
|
||||||
|
FieldDef("route_map_in", length=128, role="meta", display_name="RM In"),
|
||||||
|
FieldDef("route_map_out", length=128, role="meta", display_name="RM Out"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_CONFIG_L2VPN_PW_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("vpn_type", length=16, indexed=True, is_key=True, display_name="Type"),
|
||||||
|
FieldDef("vpn_name", length=128, indexed=True, is_key=True, display_name="VPN"),
|
||||||
|
FieldDef("pw_name", length=128, indexed=True, is_key=True, display_name="PW"),
|
||||||
|
FieldDef("peer", length=64, role="state", display_name="Peer"),
|
||||||
|
FieldDef("vcid", length=64, role="state", display_name="VCID"),
|
||||||
|
FieldDef("encapsulation", length=32, role="meta", display_name="Encap"),
|
||||||
|
FieldDef("access_point", length=128, role="meta", display_name="Access"),
|
||||||
|
FieldDef("description", length=256, role="meta", display_name="描述"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_CONFIG_STATIC_ROUTE_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("af", length=8, indexed=True, is_key=True, display_name="AF"),
|
||||||
|
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF"),
|
||||||
|
FieldDef("prefix", length=128, indexed=True, is_key=True, display_name="Prefix"),
|
||||||
|
FieldDef("mask", length=64, indexed=True, is_key=True, display_name="Mask"),
|
||||||
|
FieldDef("next_hop", length=64, indexed=True, is_key=True, display_name="Next-Hop"),
|
||||||
|
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
|
||||||
|
FieldDef("nexthop_vrf", length=128, role="state", display_name="Nexthop-VRF"),
|
||||||
|
FieldDef("metric", length=16, role="state", display_name="Metric"),
|
||||||
|
FieldDef("bfd", length=16, role="state", display_name="BFD"),
|
||||||
|
FieldDef("track", length=64, role="meta", display_name="Track"),
|
||||||
|
FieldDef("route_name", length=128, role="meta", display_name="Name"),
|
||||||
|
FieldDef("tag", length=32, role="meta", display_name="Tag"),
|
||||||
|
FieldDef("distance", length=16, role="meta", display_name="Distance"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_CONFIG_OSPF_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("af", length=8, indexed=True, is_key=True, display_name="AF"),
|
||||||
|
FieldDef("process_id", length=32, indexed=True, is_key=True, display_name="Process"),
|
||||||
|
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF"),
|
||||||
|
FieldDef("area", length=64, indexed=True, is_key=True, display_name="Area"),
|
||||||
|
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
|
||||||
|
FieldDef("area_type", length=32, role="state", display_name="Area Type"),
|
||||||
|
FieldDef("network_type", length=32, role="state", display_name="Network"),
|
||||||
|
FieldDef("cost", length=16, role="state", display_name="Cost"),
|
||||||
|
FieldDef("hello_interval", length=16, role="state", display_name="Hello"),
|
||||||
|
FieldDef("dead_interval", length=16, role="state", display_name="Dead"),
|
||||||
|
FieldDef("bfd", length=16, role="state", display_name="BFD"),
|
||||||
|
FieldDef("router_id", length=64, role="meta", display_name="Router-ID"),
|
||||||
|
FieldDef("redistribute", length=256, role="meta", display_name="Redistribute"),
|
||||||
|
]
|
||||||
|
|
||||||
|
_CONFIG_ISIS_FIELDS: list[FieldDef] = [
|
||||||
|
FieldDef("process_id", length=32, indexed=True, is_key=True, display_name="Process"),
|
||||||
|
FieldDef("vrf", length=128, indexed=True, is_key=True, display_name="VRF"),
|
||||||
|
FieldDef("interface", length=128, indexed=True, is_key=True, is_interface=True, display_name="接口"),
|
||||||
|
FieldDef("circuit_type", length=32, role="state", display_name="Circuit"),
|
||||||
|
FieldDef("network_type", length=32, role="state", display_name="Network"),
|
||||||
|
FieldDef("ip_enable", length=8, role="state", display_name="IPv4"),
|
||||||
|
FieldDef("ipv6_enable", length=8, role="state", display_name="IPv6"),
|
||||||
|
FieldDef("metric", length=16, role="state", display_name="Metric"),
|
||||||
|
FieldDef("ipv6_metric", length=16, role="state", display_name="IPv6 Metric"),
|
||||||
|
FieldDef("passive", length=8, role="state", display_name="Passive"),
|
||||||
|
FieldDef("bfd", length=16, role="state", display_name="BFD"),
|
||||||
|
FieldDef("ipv6_bfd", length=16, role="state", display_name="IPv6 BFD"),
|
||||||
|
FieldDef("area", length=64, role="meta", display_name="Area"),
|
||||||
|
FieldDef("system_id", length=64, role="meta", display_name="System-ID"),
|
||||||
|
FieldDef("router_id", length=64, role="meta", display_name="Router-ID"),
|
||||||
|
FieldDef("is_type", length=32, role="meta", display_name="IS-Type"),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _zte_status_profiles() -> list[ParseProfile]:
|
def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
"""ZTE ZXROS status snapshots from lab show commands."""
|
"""ZTE ZXROS status snapshots from lab show commands."""
|
||||||
|
|
@ -340,6 +574,28 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
),
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.interface_detail",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="interface_detail",
|
||||||
|
parser_id="interface_detail",
|
||||||
|
title="Interface Detail (filtered)",
|
||||||
|
command_template=(
|
||||||
|
"show interface | include ifindex|BW|The port is|MTU|Negotiation|"
|
||||||
|
"Description|Current|Rate|Peak|Input|Output|utilization"
|
||||||
|
),
|
||||||
|
match=(
|
||||||
|
r"(?i)^\s*show\s+interface\s*\|\s*include\s+"
|
||||||
|
r".*\bifindex\b.*$"
|
||||||
|
),
|
||||||
|
textfsm_command="show interface",
|
||||||
|
description="Filtered show interface: BW/rate/util/description.",
|
||||||
|
fields=list(_IFACE_DETAIL_FIELDS),
|
||||||
|
tags=["interface", "rate", "status"],
|
||||||
|
sort_order=315,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
ParseProfile(
|
ParseProfile(
|
||||||
profile_id="zte.arp",
|
profile_id="zte.arp",
|
||||||
vendor_key="zte",
|
vendor_key="zte",
|
||||||
|
|
@ -371,9 +627,12 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
command_template="show running-config if-intf",
|
command_template="show running-config if-intf",
|
||||||
match=r"(?i)^\s*show\s+running-config\s+if-intf\s*$",
|
match=r"(?i)^\s*show\s+running-config\s+if-intf\s*$",
|
||||||
textfsm_command="show running-config if-intf",
|
textfsm_command="show running-config if-intf",
|
||||||
description="Interface VRF from running-config if-intf (ip vrf forwarding).",
|
description=(
|
||||||
|
"Interface VRF map for ARP enrich (aux). "
|
||||||
|
"Free-form CLI match prefers zte.config_interface (longer regex)."
|
||||||
|
),
|
||||||
fields=list(_IF_INTF_FIELDS),
|
fields=list(_IF_INTF_FIELDS),
|
||||||
tags=["interface", "vrf", "config", "status"],
|
tags=["interface", "vrf", "config", "status", "aux"],
|
||||||
sort_order=325,
|
sort_order=325,
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
|
|
@ -401,7 +660,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
parser_id="bgp_peer",
|
parser_id="bgp_peer",
|
||||||
title="BGP VPNv4 Summary",
|
title="BGP VPNv4 Summary",
|
||||||
command_template="show bgp vpnv4 unicast summary",
|
command_template="show bgp vpnv4 unicast summary",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+summary\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+summary(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp vpnv4 unicast summary",
|
textfsm_command="show bgp vpnv4 unicast summary",
|
||||||
description="BGP VPNv4 peer summary (afi=vpnv4).",
|
description="BGP VPNv4 peer summary (afi=vpnv4).",
|
||||||
fields=list(_BGP_PEER_FIELDS),
|
fields=list(_BGP_PEER_FIELDS),
|
||||||
|
|
@ -417,7 +676,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
parser_id="bgp_peer",
|
parser_id="bgp_peer",
|
||||||
title="BGP IPv4 Summary",
|
title="BGP IPv4 Summary",
|
||||||
command_template="show bgp ipv4 unicast summary",
|
command_template="show bgp ipv4 unicast summary",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+ipv4\s+unicast\s+summary\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+ipv4\s+unicast\s+summary(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp ipv4 unicast summary",
|
textfsm_command="show bgp ipv4 unicast summary",
|
||||||
description="BGP IPv4 unicast peer summary (afi=ipv4).",
|
description="BGP IPv4 unicast peer summary (afi=ipv4).",
|
||||||
fields=list(_BGP_PEER_FIELDS),
|
fields=list(_BGP_PEER_FIELDS),
|
||||||
|
|
@ -433,7 +692,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
parser_id="bgp_peer",
|
parser_id="bgp_peer",
|
||||||
title="BGP VPNv6 Summary",
|
title="BGP VPNv6 Summary",
|
||||||
command_template="show bgp vpnv6 unicast summary",
|
command_template="show bgp vpnv6 unicast summary",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+summary\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+summary(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp vpnv6 unicast summary",
|
textfsm_command="show bgp vpnv6 unicast summary",
|
||||||
description="BGP VPNv6 peer summary (afi=vpnv6).",
|
description="BGP VPNv6 peer summary (afi=vpnv6).",
|
||||||
fields=list(_BGP_PEER_FIELDS),
|
fields=list(_BGP_PEER_FIELDS),
|
||||||
|
|
@ -442,6 +701,626 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
enabled=True,
|
enabled=True,
|
||||||
kind="collect",
|
kind="collect",
|
||||||
),
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_ipv6_summary",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_peer",
|
||||||
|
parser_id="bgp_peer",
|
||||||
|
title="BGP IPv6 Summary",
|
||||||
|
command_template="show bgp ipv6 unicast summary",
|
||||||
|
match=r"(?i)^\s*show\s+bgp\s+ipv6\s+unicast\s+summary(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show bgp ipv6 unicast summary",
|
||||||
|
description="BGP IPv6 unicast peer summary (afi=ipv6).",
|
||||||
|
fields=list(_BGP_PEER_FIELDS),
|
||||||
|
tags=["bgp", "ipv6", "status"],
|
||||||
|
sort_order=365,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_vpnv4_vrf_summary",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_peer",
|
||||||
|
parser_id="bgp_peer",
|
||||||
|
title="BGP VPNv4 VRF Summary",
|
||||||
|
command_template="show bgp vpnv4 unicast vrf <vrf> summary",
|
||||||
|
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+summary(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show bgp vpnv4 unicast summary",
|
||||||
|
description="Per-VRF BGP VPNv4 peer summary.",
|
||||||
|
placeholders=[_BGP_VRF_PLACEHOLDER],
|
||||||
|
fields=list(_BGP_PEER_FIELDS),
|
||||||
|
tags=["bgp", "vpnv4", "vrf", "status"],
|
||||||
|
sort_order=370,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_vpnv6_vrf_summary",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_peer",
|
||||||
|
parser_id="bgp_peer",
|
||||||
|
title="BGP VPNv6 VRF Summary",
|
||||||
|
command_template="show bgp vpnv6 unicast vrf <vrf> summary",
|
||||||
|
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+summary(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show bgp vpnv6 unicast summary",
|
||||||
|
description="Per-VRF BGP VPNv6 peer summary.",
|
||||||
|
placeholders=[_BGP_VRF_PLACEHOLDER],
|
||||||
|
fields=list(_BGP_PEER_FIELDS),
|
||||||
|
tags=["bgp", "vpnv6", "vrf", "status"],
|
||||||
|
sort_order=375,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_evpn_summary",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_peer",
|
||||||
|
parser_id="bgp_peer",
|
||||||
|
title="BGP L2VPN EVPN Summary",
|
||||||
|
command_template="show bgp l2vpn evpn summary",
|
||||||
|
match=r"(?i)^\s*show\s+bgp\s+l2vpn\s+evpn\s+summary(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show bgp l2vpn evpn summary",
|
||||||
|
description="BGP L2VPN EVPN peer summary (afi=evpn).",
|
||||||
|
fields=list(_BGP_PEER_FIELDS),
|
||||||
|
tags=["bgp", "evpn", "status"],
|
||||||
|
sort_order=380,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_vpls_summary",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_peer",
|
||||||
|
parser_id="bgp_peer",
|
||||||
|
title="BGP L2VPN VPLS Summary",
|
||||||
|
command_template="show bgp l2vpn vpls summary",
|
||||||
|
match=r"(?i)^\s*show\s+bgp\s+l2vpn\s+vpls\s+summary(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show bgp l2vpn vpls summary",
|
||||||
|
description="BGP L2VPN VPLS peer summary (afi=vpls).",
|
||||||
|
fields=list(_BGP_PEER_FIELDS),
|
||||||
|
tags=["bgp", "vpls", "status"],
|
||||||
|
sort_order=385,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
# --- Phase 1 status ---
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.ospf_neighbor",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="ospf_neighbor",
|
||||||
|
parser_id="ospf_neighbor",
|
||||||
|
title="OSPF Neighbor",
|
||||||
|
command_template="show ip ospf neighbor | one-line",
|
||||||
|
match=r"(?i)^\s*show\s+ip\s+ospf\s+neighbor(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show ip ospf neighbor",
|
||||||
|
description="OSPF neighbor table (multi Process ID).",
|
||||||
|
fields=list(_OSPF_FIELDS),
|
||||||
|
tags=["ospf", "l3", "status"],
|
||||||
|
sort_order=390,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.vrrp_ipv4",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="vrrp",
|
||||||
|
parser_id="vrrp",
|
||||||
|
title="VRRP IPv4 Brief",
|
||||||
|
command_template="show vrrp ipv4 brief | one-line",
|
||||||
|
match=r"(?i)^\s*show\s+vrrp\s+ipv4\s+brief(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show vrrp ipv4 brief",
|
||||||
|
description="VRRP IPv4 brief (af=ipv4).",
|
||||||
|
fields=list(_VRRP_FIELDS),
|
||||||
|
tags=["vrrp", "l3", "status"],
|
||||||
|
sort_order=400,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.vrrp_ipv6",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="vrrp",
|
||||||
|
parser_id="vrrp",
|
||||||
|
title="VRRP IPv6 Brief",
|
||||||
|
command_template="show vrrp ipv6 brief | one-line",
|
||||||
|
match=r"(?i)^\s*show\s+vrrp\s+ipv6\s+brief(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show vrrp ipv6 brief",
|
||||||
|
description="VRRP IPv6 brief (af=ipv6).",
|
||||||
|
fields=list(_VRRP_FIELDS),
|
||||||
|
tags=["vrrp", "ipv6", "status"],
|
||||||
|
sort_order=405,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.optical_brief",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="optical_brief",
|
||||||
|
parser_id="optical_brief",
|
||||||
|
title="Optical Info Brief",
|
||||||
|
command_template="show opticalinfo brief | one-line",
|
||||||
|
match=r"(?i)^\s*show\s+opticalinfo\s+brief(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show opticalinfo brief",
|
||||||
|
description="Transceiver Rx/Tx power and status.",
|
||||||
|
fields=list(_OPTICAL_FIELDS),
|
||||||
|
tags=["optical", "interface", "status"],
|
||||||
|
sort_order=410,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
# --- Phase 3 BGP neighbor routes ---
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_vpnv4_neighbor_in",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_route",
|
||||||
|
parser_id="bgp_route",
|
||||||
|
title="BGP VPNv4 Neighbor In",
|
||||||
|
command_template="show bgp vpnv4 unicast neighbor in <neighbor>",
|
||||||
|
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show bgp vpnv4 unicast neighbor in",
|
||||||
|
description="Routes learned from VPNv4 neighbor; summary aux for peer state.",
|
||||||
|
placeholders=[_BGP_NEIGHBOR_PLACEHOLDER],
|
||||||
|
fields=list(_BGP_ROUTE_FIELDS),
|
||||||
|
tags=["bgp", "vpnv4", "route"],
|
||||||
|
sort_order=420,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_summary")],
|
||||||
|
enrich_joins=[
|
||||||
|
EnrichJoin(
|
||||||
|
from_aux="bgp_summary",
|
||||||
|
on="neighbor",
|
||||||
|
take=("as_num", "state", "pfx_rcd"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_vpnv4_neighbor_out",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_route",
|
||||||
|
parser_id="bgp_route",
|
||||||
|
title="BGP VPNv4 Neighbor Out",
|
||||||
|
command_template="show bgp vpnv4 unicast neighbor out <neighbor>",
|
||||||
|
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show bgp vpnv4 unicast neighbor out",
|
||||||
|
description="Routes advertised to VPNv4 neighbor (large; bind neighbor).",
|
||||||
|
placeholders=[_BGP_NEIGHBOR_PLACEHOLDER],
|
||||||
|
fields=list(_BGP_ROUTE_FIELDS),
|
||||||
|
tags=["bgp", "vpnv4", "route"],
|
||||||
|
sort_order=425,
|
||||||
|
enabled=False,
|
||||||
|
kind="collect",
|
||||||
|
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_summary")],
|
||||||
|
enrich_joins=[
|
||||||
|
EnrichJoin(
|
||||||
|
from_aux="bgp_summary",
|
||||||
|
on="neighbor",
|
||||||
|
take=("as_num", "state", "pfx_rcd"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_vpnv6_neighbor_in",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_route",
|
||||||
|
parser_id="bgp_route",
|
||||||
|
title="BGP VPNv6 Neighbor In",
|
||||||
|
command_template="show bgp vpnv6 unicast neighbor in <neighbor>",
|
||||||
|
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show bgp vpnv6 unicast neighbor in",
|
||||||
|
description="Routes learned from VPNv6 neighbor; summary aux for peer state.",
|
||||||
|
placeholders=[_BGP_NEIGHBOR_PLACEHOLDER],
|
||||||
|
fields=list(_BGP_ROUTE_FIELDS),
|
||||||
|
tags=["bgp", "vpnv6", "route"],
|
||||||
|
sort_order=426,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv6_summary")],
|
||||||
|
enrich_joins=[
|
||||||
|
EnrichJoin(
|
||||||
|
from_aux="bgp_summary",
|
||||||
|
on="neighbor",
|
||||||
|
take=("as_num", "state", "pfx_rcd"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_vpnv6_neighbor_out",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_route",
|
||||||
|
parser_id="bgp_route",
|
||||||
|
title="BGP VPNv6 Neighbor Out",
|
||||||
|
command_template="show bgp vpnv6 unicast neighbor out <neighbor>",
|
||||||
|
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show bgp vpnv6 unicast neighbor out",
|
||||||
|
description="Routes advertised to VPNv6 neighbor (large; bind neighbor).",
|
||||||
|
placeholders=[_BGP_NEIGHBOR_PLACEHOLDER],
|
||||||
|
fields=list(_BGP_ROUTE_FIELDS),
|
||||||
|
tags=["bgp", "vpnv6", "route"],
|
||||||
|
sort_order=427,
|
||||||
|
enabled=False,
|
||||||
|
kind="collect",
|
||||||
|
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv6_summary")],
|
||||||
|
enrich_joins=[
|
||||||
|
EnrichJoin(
|
||||||
|
from_aux="bgp_summary",
|
||||||
|
on="neighbor",
|
||||||
|
take=("as_num", "state", "pfx_rcd"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_vpnv4_vrf_neighbor_in",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_route",
|
||||||
|
parser_id="bgp_route",
|
||||||
|
title="BGP VPNv4 VRF Neighbor In",
|
||||||
|
command_template="show bgp vpnv4 unicast vrf <vrf> neighbor in <neighbor>",
|
||||||
|
match=(
|
||||||
|
r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+"
|
||||||
|
r"neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
|
||||||
|
),
|
||||||
|
textfsm_command="show bgp vpnv4 unicast neighbor in",
|
||||||
|
description="Per-VRF routes learned from neighbor; VRF summary aux.",
|
||||||
|
placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER],
|
||||||
|
fields=list(_BGP_ROUTE_FIELDS),
|
||||||
|
tags=["bgp", "vpnv4", "vrf", "route"],
|
||||||
|
sort_order=430,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_vrf_summary")],
|
||||||
|
enrich_joins=[
|
||||||
|
EnrichJoin(
|
||||||
|
from_aux="bgp_summary",
|
||||||
|
on="neighbor",
|
||||||
|
take=("as_num", "state", "pfx_rcd"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_vpnv4_vrf_neighbor_out",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_route",
|
||||||
|
parser_id="bgp_route",
|
||||||
|
title="BGP VPNv4 VRF Neighbor Out",
|
||||||
|
command_template="show bgp vpnv4 unicast vrf <vrf> neighbor out <neighbor>",
|
||||||
|
match=(
|
||||||
|
r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+"
|
||||||
|
r"neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
|
||||||
|
),
|
||||||
|
textfsm_command="show bgp vpnv4 unicast neighbor out",
|
||||||
|
description="Per-VRF routes advertised to neighbor (may be large).",
|
||||||
|
placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER],
|
||||||
|
fields=list(_BGP_ROUTE_FIELDS),
|
||||||
|
tags=["bgp", "vpnv4", "vrf", "route"],
|
||||||
|
sort_order=435,
|
||||||
|
enabled=False,
|
||||||
|
kind="collect",
|
||||||
|
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_vrf_summary")],
|
||||||
|
enrich_joins=[
|
||||||
|
EnrichJoin(
|
||||||
|
from_aux="bgp_summary",
|
||||||
|
on="neighbor",
|
||||||
|
take=("as_num", "state", "pfx_rcd"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_vpnv6_vrf_neighbor_in",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_route",
|
||||||
|
parser_id="bgp_route",
|
||||||
|
title="BGP VPNv6 VRF Neighbor In",
|
||||||
|
command_template="show bgp vpnv6 unicast vrf <vrf> neighbor in <neighbor>",
|
||||||
|
match=(
|
||||||
|
r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+"
|
||||||
|
r"neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
|
||||||
|
),
|
||||||
|
textfsm_command="show bgp vpnv6 unicast neighbor in",
|
||||||
|
description="Per-VRF VPNv6 routes learned from neighbor.",
|
||||||
|
placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER],
|
||||||
|
fields=list(_BGP_ROUTE_FIELDS),
|
||||||
|
tags=["bgp", "vpnv6", "vrf", "route"],
|
||||||
|
sort_order=440,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv6_vrf_summary")],
|
||||||
|
enrich_joins=[
|
||||||
|
EnrichJoin(
|
||||||
|
from_aux="bgp_summary",
|
||||||
|
on="neighbor",
|
||||||
|
take=("as_num", "state", "pfx_rcd"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.bgp_vpnv6_vrf_neighbor_out",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="bgp_route",
|
||||||
|
parser_id="bgp_route",
|
||||||
|
title="BGP VPNv6 VRF Neighbor Out",
|
||||||
|
command_template="show bgp vpnv6 unicast vrf <vrf> neighbor out <neighbor>",
|
||||||
|
match=(
|
||||||
|
r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+"
|
||||||
|
r"neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
|
||||||
|
),
|
||||||
|
textfsm_command="show bgp vpnv6 unicast neighbor out",
|
||||||
|
description="Per-VRF VPNv6 routes advertised to neighbor.",
|
||||||
|
placeholders=[_BGP_VRF_PLACEHOLDER, _BGP_NEIGHBOR_PLACEHOLDER],
|
||||||
|
fields=list(_BGP_ROUTE_FIELDS),
|
||||||
|
tags=["bgp", "vpnv6", "vrf", "route"],
|
||||||
|
sort_order=445,
|
||||||
|
enabled=False,
|
||||||
|
kind="collect",
|
||||||
|
aux_commands=[AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv6_vrf_summary")],
|
||||||
|
enrich_joins=[
|
||||||
|
EnrichJoin(
|
||||||
|
from_aux="bgp_summary",
|
||||||
|
on="neighbor",
|
||||||
|
take=("as_num", "state", "pfx_rcd"),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
# --- Phase 4 forwarding / L2 ---
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.ip_route_vrf",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="ip_route",
|
||||||
|
parser_id="ip_route",
|
||||||
|
title="IPv4 Forwarding VRF",
|
||||||
|
command_template="show ip forwarding route vrf <vrf>",
|
||||||
|
match=r"(?i)^\s*show\s+ip\s+forwarding\s+route\s+vrf\s+(?P<vrf>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show ip forwarding route",
|
||||||
|
description="IPv4 FIB for one VRF.",
|
||||||
|
placeholders=[_BGP_VRF_PLACEHOLDER],
|
||||||
|
fields=list(_IP_ROUTE_FIELDS),
|
||||||
|
tags=["route", "ipv4", "vrf"],
|
||||||
|
sort_order=450,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.ip_route",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="ip_route",
|
||||||
|
parser_id="ip_route",
|
||||||
|
title="IPv4 Forwarding Global",
|
||||||
|
command_template="show ip forwarding route",
|
||||||
|
match=r"(?i)^\s*show\s+ip\s+forwarding\s+route(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show ip forwarding route",
|
||||||
|
description="Global IPv4 FIB (large; disabled by default).",
|
||||||
|
fields=list(_IP_ROUTE_FIELDS),
|
||||||
|
tags=["route", "ipv4"],
|
||||||
|
sort_order=455,
|
||||||
|
enabled=False,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.ipv6_route_vrf",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="ipv6_route",
|
||||||
|
parser_id="ipv6_route",
|
||||||
|
title="IPv6 Forwarding VRF",
|
||||||
|
command_template="show ipv6 forwarding route vrf <vrf>",
|
||||||
|
match=r"(?i)^\s*show\s+ipv6\s+forwarding\s+route\s+vrf\s+(?P<vrf>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show ipv6 forwarding route",
|
||||||
|
description="IPv6 FIB for one VRF.",
|
||||||
|
placeholders=[_BGP_VRF_PLACEHOLDER],
|
||||||
|
fields=list(_IPV6_ROUTE_FIELDS),
|
||||||
|
tags=["route", "ipv6", "vrf"],
|
||||||
|
sort_order=460,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.ipv6_route",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="ipv6_route",
|
||||||
|
parser_id="ipv6_route",
|
||||||
|
title="IPv6 Forwarding Global",
|
||||||
|
command_template="show ipv6 forwarding route",
|
||||||
|
match=r"(?i)^\s*show\s+ipv6\s+forwarding\s+route(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show ipv6 forwarding route",
|
||||||
|
description="Global IPv6 FIB (large; disabled by default).",
|
||||||
|
fields=list(_IPV6_ROUTE_FIELDS),
|
||||||
|
tags=["route", "ipv6"],
|
||||||
|
sort_order=465,
|
||||||
|
enabled=False,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.l2vpn_pw",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="l2vpn_pw",
|
||||||
|
parser_id="l2vpn_pw",
|
||||||
|
title="L2VPN PW State",
|
||||||
|
command_template="show l2vpn forwardinfo | one-line",
|
||||||
|
match=r"(?i)^\s*show\s+l2vpn\s+forwardinfo(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show l2vpn forwardinfo",
|
||||||
|
description="L2VPN pseudowire forwardinfo state.",
|
||||||
|
fields=list(_L2VPN_PW_FIELDS),
|
||||||
|
tags=["l2vpn", "pw", "status"],
|
||||||
|
sort_order=470,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.l2vpn_mac",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="l2vpn_mac",
|
||||||
|
parser_id="l2vpn_mac",
|
||||||
|
title="L2VPN MAC Table",
|
||||||
|
command_template="show mac l2vpn | one-line",
|
||||||
|
match=r"(?i)^\s*show\s+mac\s+l2vpn(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show mac l2vpn",
|
||||||
|
description="L2VPN MAC table (large; disabled by default).",
|
||||||
|
fields=list(_L2VPN_MAC_FIELDS),
|
||||||
|
tags=["l2vpn", "mac"],
|
||||||
|
sort_order=480,
|
||||||
|
enabled=False,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.evpn_mac",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="evpn_mac",
|
||||||
|
parser_id="evpn_mac",
|
||||||
|
title="BGP EVPN MAC",
|
||||||
|
command_template="show bgp evpn mac | one-line",
|
||||||
|
match=r"(?i)^\s*show\s+bgp\s+evpn\s+mac(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show bgp evpn mac",
|
||||||
|
description="BGP EVPN MAC NLRI (large; disabled by default).",
|
||||||
|
fields=list(_EVPN_MAC_FIELDS),
|
||||||
|
tags=["bgp", "evpn", "mac"],
|
||||||
|
sort_order=490,
|
||||||
|
enabled=False,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
# --- Config intent (P0) ---
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.config_vrf",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="config_vrf",
|
||||||
|
parser_id="config_vrf",
|
||||||
|
title="Config VRF Intent",
|
||||||
|
command_template="show running-config vrf",
|
||||||
|
match=r"(?i)^\s*show\s+running-config\s+vrf(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show running-config vrf",
|
||||||
|
description="VRF RD/RT/AF intent from running-config vrf (no secrets).",
|
||||||
|
fields=list(_CONFIG_VRF_FIELDS),
|
||||||
|
tags=["config", "vrf", "intent"],
|
||||||
|
sort_order=500,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.config_interface",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="config_interface",
|
||||||
|
parser_id="config_interface",
|
||||||
|
title="Config Interface Intent",
|
||||||
|
command_template="show running-config if-intf",
|
||||||
|
match=r"(?i)^\s*show\s+running-config\s+if-intf(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show running-config if-intf",
|
||||||
|
description="Interface VRF/IP/admin intent (richer than if_intf aux map).",
|
||||||
|
fields=list(_CONFIG_IFACE_FIELDS),
|
||||||
|
tags=["config", "interface", "intent"],
|
||||||
|
sort_order=510,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.config_bgp_peer",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="config_bgp_peer",
|
||||||
|
parser_id="config_bgp_peer",
|
||||||
|
title="Config BGP Peer Intent",
|
||||||
|
command_template="show running-config bgp",
|
||||||
|
match=r"(?i)^\s*show\s+running-config\s+bgp(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show running-config bgp",
|
||||||
|
description="BGP neighbor AF activate / remote-as intent (passwords skipped).",
|
||||||
|
fields=list(_CONFIG_BGP_PEER_FIELDS),
|
||||||
|
tags=["config", "bgp", "intent"],
|
||||||
|
sort_order=520,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.config_l2vpn_pw",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="config_l2vpn_pw",
|
||||||
|
parser_id="config_l2vpn_pw",
|
||||||
|
title="Config L2VPN PW Intent",
|
||||||
|
command_template="show running-config l2vpn",
|
||||||
|
match=r"(?i)^\s*show\s+running-config\s+l2vpn(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show running-config l2vpn",
|
||||||
|
description="VPWS/VPLS pseudo-wire peer/vcid intent.",
|
||||||
|
fields=list(_CONFIG_L2VPN_PW_FIELDS),
|
||||||
|
tags=["config", "l2vpn", "intent"],
|
||||||
|
sort_order=530,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.config_static_route",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="config_static_route",
|
||||||
|
parser_id="config_static_route",
|
||||||
|
title="Config Static Route (IPv4)",
|
||||||
|
command_template="show running-config static",
|
||||||
|
match=r"(?i)^\s*show\s+running-config\s+static(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show running-config static",
|
||||||
|
description="IPv4 static route intent from running-config static.",
|
||||||
|
fields=list(_CONFIG_STATIC_ROUTE_FIELDS),
|
||||||
|
tags=["config", "static", "route", "intent"],
|
||||||
|
sort_order=540,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.config_static_route_v6",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="config_static_route",
|
||||||
|
parser_id="config_static_route",
|
||||||
|
title="Config Static Route (IPv6)",
|
||||||
|
command_template="show running-config ipv6-static-route",
|
||||||
|
match=r"(?i)^\s*show\s+running-config\s+ipv6-static-route(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show running-config ipv6-static-route",
|
||||||
|
description="IPv6 static route intent from running-config ipv6-static-route.",
|
||||||
|
fields=list(_CONFIG_STATIC_ROUTE_FIELDS),
|
||||||
|
tags=["config", "static", "route", "ipv6", "intent"],
|
||||||
|
sort_order=541,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.config_ospf",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="config_ospf",
|
||||||
|
parser_id="config_ospf",
|
||||||
|
title="Config OSPF Intent (v2)",
|
||||||
|
command_template="show running-config ospfv2",
|
||||||
|
match=r"(?i)^\s*show\s+running-config\s+ospfv2(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show running-config ospfv2",
|
||||||
|
description="OSPFv2 process/area/interface intent (auth secrets skipped).",
|
||||||
|
fields=list(_CONFIG_OSPF_FIELDS),
|
||||||
|
tags=["config", "ospf", "intent"],
|
||||||
|
sort_order=550,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.config_ospf_v3",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="config_ospf",
|
||||||
|
parser_id="config_ospf",
|
||||||
|
title="Config OSPF Intent (v3)",
|
||||||
|
command_template="show running-config ospfv3",
|
||||||
|
match=r"(?i)^\s*show\s+running-config\s+ospfv3(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show running-config ospfv3",
|
||||||
|
description="OSPFv3 process/area/interface intent.",
|
||||||
|
fields=list(_CONFIG_OSPF_FIELDS),
|
||||||
|
tags=["config", "ospf", "ipv6", "intent"],
|
||||||
|
sort_order=551,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
|
ParseProfile(
|
||||||
|
profile_id="zte.config_isis",
|
||||||
|
vendor_key="zte",
|
||||||
|
metric_id="config_isis",
|
||||||
|
parser_id="config_isis",
|
||||||
|
title="Config ISIS Intent",
|
||||||
|
command_template="show running-config isis",
|
||||||
|
match=r"(?i)^\s*show\s+running-config\s+isis(?:\s*\|\s*one-line)?\s*$",
|
||||||
|
textfsm_command="show running-config isis",
|
||||||
|
description="IS-IS process/interface intent (auth secrets skipped).",
|
||||||
|
fields=list(_CONFIG_ISIS_FIELDS),
|
||||||
|
tags=["config", "isis", "intent"],
|
||||||
|
sort_order=560,
|
||||||
|
enabled=True,
|
||||||
|
kind="collect",
|
||||||
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ def apply_biz_state_schema(conn: Connection) -> None:
|
||||||
# Keep lightweight indexes that older DBs might miss.
|
# Keep lightweight indexes that older DBs might miss.
|
||||||
for sql in (
|
for sql in (
|
||||||
"ALTER TABLE biz_compare_template ADD COLUMN IF NOT EXISTS metrics_json JSON DEFAULT '[]'",
|
"ALTER TABLE biz_compare_template ADD COLUMN IF NOT EXISTS metrics_json JSON DEFAULT '[]'",
|
||||||
|
"ALTER TABLE biz_compare_template ADD COLUMN IF NOT EXISTS iface_normalize_json JSON DEFAULT '[]'",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_state_task_status ON biz_state_task (status)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_state_task_status ON biz_state_task (status)",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_task_id ON biz_state_batch (task_id)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_task_id ON biz_state_batch (task_id)",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_command_batch_id ON biz_state_batch_command (batch_id)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_command_batch_id ON biz_state_batch_command (batch_id)",
|
||||||
|
|
@ -63,6 +64,19 @@ def apply_biz_state_schema(conn: Connection) -> None:
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_monitor_template_compare ON biz_monitor_template (compare_template_id)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_monitor_template_compare ON biz_monitor_template (compare_template_id)",
|
||||||
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS monitor_template_id VARCHAR(64) DEFAULT ''",
|
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS monitor_template_id VARCHAR(64) DEFAULT ''",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_migration_project_monitor_tpl ON biz_migration_project (monitor_template_id)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_migration_project_monitor_tpl ON biz_migration_project (monitor_template_id)",
|
||||||
|
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS old_hf_task_id VARCHAR(64) DEFAULT ''",
|
||||||
|
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS new_hf_task_id VARCHAR(64) DEFAULT ''",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_biz_migration_project_old_hf ON biz_migration_project (old_hf_task_id)",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_biz_migration_project_new_hf ON biz_migration_project (new_hf_task_id)",
|
||||||
|
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS collect_metric_ids_json JSON DEFAULT '[]'",
|
||||||
|
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS hf_interval_sec INTEGER DEFAULT 60",
|
||||||
|
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS hf_start_at TIMESTAMP",
|
||||||
|
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS hf_end_at TIMESTAMP",
|
||||||
|
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS metric_interval_sec_json JSON DEFAULT '{}'",
|
||||||
|
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS old_hf_bindings_json JSON DEFAULT '[]'",
|
||||||
|
"ALTER TABLE biz_migration_project ADD COLUMN IF NOT EXISTS new_hf_bindings_json JSON DEFAULT '[]'",
|
||||||
|
"ALTER TABLE biz_state_task ADD COLUMN IF NOT EXISTS purpose VARCHAR(32) DEFAULT ''",
|
||||||
|
"CREATE INDEX IF NOT EXISTS ix_biz_state_task_purpose ON biz_state_task (purpose)",
|
||||||
):
|
):
|
||||||
try:
|
try:
|
||||||
_run_sql(conn, sql)
|
_run_sql(conn, sql)
|
||||||
|
|
|
||||||
|
|
@ -156,6 +156,7 @@ def create_task(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
vendor=vendor,
|
vendor=vendor,
|
||||||
device_type=device_type,
|
device_type=device_type,
|
||||||
note=str(body.get("note") or "")[:256],
|
note=str(body.get("note") or "")[:256],
|
||||||
|
purpose=str(body.get("purpose") or "")[:32],
|
||||||
status=status,
|
status=status,
|
||||||
interval_sec=max(60, int(body.get("interval_sec") or 3600)),
|
interval_sec=max(60, int(body.get("interval_sec") or 3600)),
|
||||||
retention_days=max(1, min(3650, int(body.get("retention_days") or 30))),
|
retention_days=max(1, min(3650, int(body.get("retention_days") or 30))),
|
||||||
|
|
@ -234,6 +235,8 @@ def update_task(db: Session, task_id: str, body: dict[str, Any]) -> dict[str, An
|
||||||
raise HTTPException(status_code=404, detail="task_not_found")
|
raise HTTPException(status_code=404, detail="task_not_found")
|
||||||
if "note" in body:
|
if "note" in body:
|
||||||
task.note = str(body.get("note") or "")[:256]
|
task.note = str(body.get("note") or "")[:256]
|
||||||
|
if "purpose" in body and body["purpose"] is not None:
|
||||||
|
task.purpose = str(body.get("purpose") or "")[:32]
|
||||||
if "interval_sec" in body:
|
if "interval_sec" in body:
|
||||||
task.interval_sec = max(60, int(body.get("interval_sec") or 3600))
|
task.interval_sec = max(60, int(body.get("interval_sec") or 3600))
|
||||||
if "retention_days" in body:
|
if "retention_days" in body:
|
||||||
|
|
@ -253,6 +256,10 @@ def update_task(db: Session, task_id: str, body: dict[str, Any]) -> dict[str, An
|
||||||
st = str(body.get("status") or "").strip()
|
st = str(body.get("status") or "").strip()
|
||||||
if st in ("draft", "running", "paused", "stopped"):
|
if st in ("draft", "running", "paused", "stopped"):
|
||||||
if st == "running":
|
if st == "running":
|
||||||
|
# Cutover HF: NE-scoped catalogs; placeholder bindings optional / stamped later
|
||||||
|
purpose = str(getattr(task, "purpose", None) or "").strip()
|
||||||
|
note = str(getattr(task, "note", None) or "")
|
||||||
|
if purpose != "cutover_hf" and not note.startswith("割接高频"):
|
||||||
_assert_bindings_ready(db, task.id)
|
_assert_bindings_ready(db, task.id)
|
||||||
task.status = st
|
task.status = st
|
||||||
task.updated_at = _utcnow()
|
task.updated_at = _utcnow()
|
||||||
|
|
@ -350,6 +357,7 @@ def get_task(db: Session, task_id: str) -> dict[str, Any]:
|
||||||
"vendor": task.vendor,
|
"vendor": task.vendor,
|
||||||
"device_type": task.device_type,
|
"device_type": task.device_type,
|
||||||
"note": task.note,
|
"note": task.note,
|
||||||
|
"purpose": str(getattr(task, "purpose", None) or ""),
|
||||||
"status": task.status,
|
"status": task.status,
|
||||||
"interval_sec": task.interval_sec,
|
"interval_sec": task.interval_sec,
|
||||||
"retention_days": int(getattr(task, "retention_days", None) or 30),
|
"retention_days": int(getattr(task, "retention_days", None) or 30),
|
||||||
|
|
@ -367,8 +375,24 @@ def get_task(db: Session, task_id: str) -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def list_tasks(db: Session) -> list[dict[str, Any]]:
|
def list_tasks(db: Session, *, purpose: str | None = None) -> list[dict[str, Any]]:
|
||||||
rows = db.query(BizStateTask).order_by(BizStateTask.updated_at.desc()).all()
|
from sqlalchemy import or_
|
||||||
|
|
||||||
|
q = db.query(BizStateTask)
|
||||||
|
purpose_f = str(purpose or "").strip()
|
||||||
|
if purpose_f:
|
||||||
|
if purpose_f == "portrait":
|
||||||
|
# Portrait = empty purpose or explicit portrait (exclude cutover_hf)
|
||||||
|
q = q.filter(
|
||||||
|
or_(
|
||||||
|
BizStateTask.purpose == "",
|
||||||
|
BizStateTask.purpose == "portrait",
|
||||||
|
BizStateTask.purpose.is_(None),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
q = q.filter(BizStateTask.purpose == purpose_f)
|
||||||
|
rows = q.order_by(BizStateTask.updated_at.desc()).all()
|
||||||
return [
|
return [
|
||||||
{
|
{
|
||||||
"id": t.id,
|
"id": t.id,
|
||||||
|
|
@ -378,6 +402,7 @@ def list_tasks(db: Session) -> list[dict[str, Any]]:
|
||||||
"ne_ip": t.ne_ip,
|
"ne_ip": t.ne_ip,
|
||||||
"vendor": t.vendor,
|
"vendor": t.vendor,
|
||||||
"note": t.note,
|
"note": t.note,
|
||||||
|
"purpose": str(getattr(t, "purpose", None) or ""),
|
||||||
"status": t.status,
|
"status": t.status,
|
||||||
"interval_sec": t.interval_sec,
|
"interval_sec": t.interval_sec,
|
||||||
"collect_running": bool(t.collect_running),
|
"collect_running": bool(t.collect_running),
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ class TaskCreateIn(BaseModel):
|
||||||
vendor: str = ""
|
vendor: str = ""
|
||||||
device_type: str = ""
|
device_type: str = ""
|
||||||
note: str = ""
|
note: str = ""
|
||||||
|
purpose: str = ""
|
||||||
status: str = "draft"
|
status: str = "draft"
|
||||||
interval_sec: int = 3600
|
interval_sec: int = 3600
|
||||||
retention_days: int = 30
|
retention_days: int = 30
|
||||||
|
|
@ -54,6 +55,7 @@ class TaskCreateIn(BaseModel):
|
||||||
|
|
||||||
class TaskPatchIn(BaseModel):
|
class TaskPatchIn(BaseModel):
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
|
purpose: str | None = None
|
||||||
interval_sec: int | None = None
|
interval_sec: int | None = None
|
||||||
retention_days: int | None = None
|
retention_days: int | None = None
|
||||||
daily_keep_enabled: bool | None = None
|
daily_keep_enabled: bool | None = None
|
||||||
|
|
@ -151,8 +153,8 @@ def api_set_bindings(
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks")
|
@router.get("/tasks")
|
||||||
def api_list_tasks(db: Session = Depends(get_db)) -> dict[str, Any]:
|
def api_list_tasks(purpose: str = "", db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
return {"items": svc.list_tasks(db)}
|
return {"items": svc.list_tasks(db, purpose=purpose or None)}
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks")
|
@router.post("/tasks")
|
||||||
|
|
@ -285,6 +287,8 @@ class TemplateIn(BaseModel):
|
||||||
note: str = ""
|
note: str = ""
|
||||||
# Preferred: multi-metric sheets
|
# Preferred: multi-metric sheets
|
||||||
metrics: list[TemplateMetricIn] | None = None
|
metrics: list[TemplateMetricIn] | None = None
|
||||||
|
# Template-owned interface type aliases (GE→gei, …)
|
||||||
|
iface_normalize_rules: list[dict[str, str]] | None = None
|
||||||
# Legacy single-metric fields (still accepted)
|
# Legacy single-metric fields (still accepted)
|
||||||
metric_id: str = "lldp_neighbor"
|
metric_id: str = "lldp_neighbor"
|
||||||
key_fields: list[str] = Field(default_factory=list)
|
key_fields: list[str] = Field(default_factory=list)
|
||||||
|
|
@ -300,6 +304,7 @@ class TemplatePatchIn(BaseModel):
|
||||||
name: str | None = None
|
name: str | None = None
|
||||||
note: str | None = None
|
note: str | None = None
|
||||||
metrics: list[TemplateMetricIn] | None = None
|
metrics: list[TemplateMetricIn] | None = None
|
||||||
|
iface_normalize_rules: list[dict[str, str]] | None = None
|
||||||
metric_id: str | None = None
|
metric_id: str | None = None
|
||||||
key_fields: list[str] | None = None
|
key_fields: list[str] | None = None
|
||||||
iface_fields: list[str] | None = None
|
iface_fields: list[str] | None = None
|
||||||
|
|
|
||||||
|
|
@ -77,12 +77,62 @@ def shutdown_biz_state_dispatch_pool(*, wait: bool = False) -> None:
|
||||||
_dispatch_pool = None
|
_dispatch_pool = None
|
||||||
|
|
||||||
|
|
||||||
|
def _sync_cutover_hf_windows() -> None:
|
||||||
|
"""Pause/resume cutover_hf tasks according to migration project hf_start/hf_end."""
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
from .biz_migration.service import (
|
||||||
|
PURPOSE_CUTOVER_HF,
|
||||||
|
_all_hf_task_ids,
|
||||||
|
_hf_window_status,
|
||||||
|
)
|
||||||
|
from .biz_state import service as biz_svc
|
||||||
|
from .models import BizMigrationProject
|
||||||
|
|
||||||
|
projects = db.query(BizMigrationProject).all()
|
||||||
|
for proj in projects:
|
||||||
|
want = _hf_window_status(proj)
|
||||||
|
tids = _all_hf_task_ids(proj, "old") + _all_hf_task_ids(proj, "new")
|
||||||
|
for tid in tids:
|
||||||
|
task = db.get(BizStateTask, tid)
|
||||||
|
if not task:
|
||||||
|
continue
|
||||||
|
purpose = str(getattr(task, "purpose", None) or "").strip()
|
||||||
|
if purpose and purpose != PURPOSE_CUTOVER_HF:
|
||||||
|
continue
|
||||||
|
if want == "paused" and task.status == "running":
|
||||||
|
try:
|
||||||
|
patch = {"status": "paused"}
|
||||||
|
if purpose != PURPOSE_CUTOVER_HF:
|
||||||
|
patch["purpose"] = PURPOSE_CUTOVER_HF
|
||||||
|
biz_svc.update_task(db, tid, patch)
|
||||||
|
except Exception:
|
||||||
|
_log.exception("pause hf window failed task=%s", tid)
|
||||||
|
elif want == "running" and task.status == "paused":
|
||||||
|
try:
|
||||||
|
patch = {"status": "running"}
|
||||||
|
if purpose != PURPOSE_CUTOVER_HF:
|
||||||
|
patch["purpose"] = PURPOSE_CUTOVER_HF
|
||||||
|
biz_svc.update_task(db, tid, patch)
|
||||||
|
except Exception:
|
||||||
|
_log.exception("resume hf window failed task=%s", tid)
|
||||||
|
except Exception:
|
||||||
|
_log.exception("cutover hf window sync failed")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
def try_dispatch_due_tasks() -> int:
|
def try_dispatch_due_tasks() -> int:
|
||||||
import time as _time
|
import time as _time
|
||||||
|
|
||||||
global _last_tick_mono
|
global _last_tick_mono
|
||||||
_last_tick_mono = _time.monotonic()
|
_last_tick_mono = _time.monotonic()
|
||||||
|
|
||||||
|
try:
|
||||||
|
_sync_cutover_hf_windows()
|
||||||
|
except Exception:
|
||||||
|
_log.exception("hf window sync tick failed")
|
||||||
|
|
||||||
db = SessionLocal()
|
db = SessionLocal()
|
||||||
try:
|
try:
|
||||||
tasks = (
|
tasks = (
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,16 @@ zte/zte_zxros_show_interface.textfsm, .*, zte_zxros, sh[[ow]] int[[erface]] .+
|
||||||
zte/zte_zxros_show_ip_vrf.textfsm, .*, zte_zxros, sh[[ow]] ip vrf$
|
zte/zte_zxros_show_ip_vrf.textfsm, .*, zte_zxros, sh[[ow]] ip vrf$
|
||||||
zte/zte_zxros_show_arp.textfsm, .*, zte_zxros, sh[[ow]] arp
|
zte/zte_zxros_show_arp.textfsm, .*, zte_zxros, sh[[ow]] arp
|
||||||
zte/zte_zxros_show_running_config_if_intf.textfsm, .*, zte_zxros, sh[[ow]] run[[ning-config]] if-intf
|
zte/zte_zxros_show_running_config_if_intf.textfsm, .*, zte_zxros, sh[[ow]] run[[ning-config]] if-intf
|
||||||
|
zte/zte_zxros_show_bgp_summary.textfsm, .*, zte_zxros, sh[[ow]] bgp .+ sum[[mary]]
|
||||||
|
zte/zte_zxros_show_ospf_neighbor.textfsm, .*, zte_zxros, sh[[ow]] ip ospf nei[[ghbor]]
|
||||||
|
zte/zte_zxros_show_vrrp_brief.textfsm, .*, zte_zxros, sh[[ow]] vrrp .+ br[[ief]]
|
||||||
|
zte/zte_zxros_show_opticalinfo_brief.textfsm, .*, zte_zxros, sh[[ow]] opticalinfo br[[ief]]
|
||||||
|
zte/zte_zxros_show_bgp_neighbor_routes.textfsm, .*, zte_zxros, sh[[ow]] bgp .+ nei[[ghbor]] (in|out)
|
||||||
|
zte/zte_zxros_show_ip_forwarding_route.textfsm, .*, zte_zxros, sh[[ow]] ip forw[[arding]] rou[[te]]
|
||||||
|
zte/zte_zxros_show_ipv6_forwarding_route.textfsm, .*, zte_zxros, sh[[ow]] ipv6 forw[[arding]] rou[[te]]
|
||||||
|
zte/zte_zxros_show_l2vpn_forwardinfo.textfsm, .*, zte_zxros, sh[[ow]] l2vpn forw[[ardinfo]]
|
||||||
|
zte/zte_zxros_show_mac_l2vpn.textfsm, .*, zte_zxros, sh[[ow]] mac l2vpn
|
||||||
|
zte/zte_zxros_show_bgp_evpn_mac.textfsm, .*, zte_zxros, sh[[ow]] bgp evpn mac
|
||||||
|
|
||||||
# --- cisco ---
|
# --- cisco ---
|
||||||
cisco/cisco_ios_show_interfaces.textfsm, .*, cisco_ios, sh[[ow]] int[[erfaces]] .+
|
cisco/cisco_ios_show_interfaces.textfsm, .*, cisco_ios, sh[[ow]] int[[erfaces]] .+
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
Value NETWORK (\S+)
|
||||||
|
Value NEXT_HOP (\S+)
|
||||||
|
Value METRIC (\S*)
|
||||||
|
Value LOC_PRF (\S*)
|
||||||
|
Value RT_PRF (\S*)
|
||||||
|
Value PATH (.*)
|
||||||
|
|
||||||
|
Start
|
||||||
|
^\s*Network\s+Next\s+Hop -> Routes
|
||||||
|
^Status\s+codes
|
||||||
|
^Origin\s+codes
|
||||||
|
^Route\s+Distinguisher
|
||||||
|
^\s*$$
|
||||||
|
^.* -> Start
|
||||||
|
|
||||||
|
Routes
|
||||||
|
^Route\s+Distinguisher
|
||||||
|
^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${RT_PRF}\s+${PATH}\s*$$ -> Record
|
||||||
|
^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record
|
||||||
|
^\s*$$
|
||||||
|
^\S+\s*#\s*$$ -> End
|
||||||
|
^.*#\s*$$ -> End
|
||||||
|
^.* -> Routes
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
Value NETWORK (\S+)
|
||||||
|
Value NEXT_HOP (\S+)
|
||||||
|
Value METRIC (\S*)
|
||||||
|
Value LOC_PRF (\S*)
|
||||||
|
Value TAG (\S*)
|
||||||
|
Value PATH (.*)
|
||||||
|
|
||||||
|
Start
|
||||||
|
^Routes\s+(Learned|Sent) -> Routes
|
||||||
|
^\s*\*?\s*Network -> Routes
|
||||||
|
^Network\s+ -> Routes
|
||||||
|
^\s*$$
|
||||||
|
^.* -> Start
|
||||||
|
|
||||||
|
Routes
|
||||||
|
^Route\s+Distinguisher
|
||||||
|
^Status\s+codes
|
||||||
|
^Origin\s+codes
|
||||||
|
^Total\s+number
|
||||||
|
^\s+Valid
|
||||||
|
^\s+Invalid
|
||||||
|
^Local\s+router
|
||||||
|
^Remote\s+router
|
||||||
|
^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record
|
||||||
|
^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record
|
||||||
|
^\s*$$
|
||||||
|
^\S+\s*#\s*$$ -> End
|
||||||
|
^.*#\s*$$ -> End
|
||||||
|
^.* -> Routes
|
||||||
|
|
@ -0,0 +1,22 @@
|
||||||
|
Value NEIGHBOR (\S+)
|
||||||
|
Value VER (\d+)
|
||||||
|
Value ASN (\S+)
|
||||||
|
Value MSG_RCVD (\d+)
|
||||||
|
Value MSG_SEND (\d+)
|
||||||
|
Value UP_DOWN (\S+)
|
||||||
|
Value STATE_PFX (\S+)
|
||||||
|
|
||||||
|
Start
|
||||||
|
^Neighbor\s+Ver\s+As -> Peers
|
||||||
|
^\s*$$
|
||||||
|
^.* -> Start
|
||||||
|
|
||||||
|
Peers
|
||||||
|
^${NEIGHBOR}\s+${VER}\s+${ASN}\s+${MSG_RCVD}\s+${MSG_SEND}\s+${UP_DOWN}\s+${STATE_PFX}\s*$$ -> Record
|
||||||
|
^All\s+
|
||||||
|
^BGP\s+
|
||||||
|
^Local\s+
|
||||||
|
^\s*$$
|
||||||
|
^\S+\s*#\s*$$ -> End
|
||||||
|
^.*#\s*$$ -> End
|
||||||
|
^.* -> Peers
|
||||||
|
|
@ -1,7 +1,11 @@
|
||||||
Value Filldown IFNAME (\S+)
|
Value Filldown IFNAME (\S+)
|
||||||
Value Filldown ADMIN_OPER (up|down)
|
Value Filldown ADMIN_STATE ((?:administratively\s+)?(?:up|down))
|
||||||
|
Value Filldown IFINDEX (\d+)
|
||||||
Value Filldown DESCRIPTION (.+?)
|
Value Filldown DESCRIPTION (.+?)
|
||||||
|
Value Filldown PORT_MEDIA (\S+)
|
||||||
|
Value Filldown NEGOTIATION (\S+)
|
||||||
Value Filldown BW_RAW (\d+(?:\.\d+)?\s*[kKmMgGtT]?\s*(?:G?bit|bit)/s(?:ec)?)
|
Value Filldown BW_RAW (\d+(?:\.\d+)?\s*[kKmMgGtT]?\s*(?:G?bit|bit)/s(?:ec)?)
|
||||||
|
Value Filldown MTU (\d+)
|
||||||
Value Filldown RATE_PERIOD (\d+)
|
Value Filldown RATE_PERIOD (\d+)
|
||||||
Value Filldown INPUT_BPS ([\d.]+)
|
Value Filldown INPUT_BPS ([\d.]+)
|
||||||
Value Filldown OUTPUT_BPS ([\d.]+)
|
Value Filldown OUTPUT_BPS ([\d.]+)
|
||||||
|
|
@ -9,9 +13,12 @@ Value IN_UTIL ([\d.]+)
|
||||||
Value OUT_UTIL ([\d.]+)
|
Value OUT_UTIL ([\d.]+)
|
||||||
|
|
||||||
Start
|
Start
|
||||||
^${IFNAME}\s+is\s+${ADMIN_OPER}\b
|
^${IFNAME}\s+is\s+${ADMIN_STATE}(?:,\s*ifindex:\s*${IFINDEX})?
|
||||||
^\s*Description:\s*${DESCRIPTION}\s*$$
|
^\s*Description:\s*${DESCRIPTION}\s*$$
|
||||||
|
^\s*The\s+port\s+is\s+${PORT_MEDIA}
|
||||||
|
^\s*Negotiation\s+${NEGOTIATION}
|
||||||
^\s*BW\s+${BW_RAW}
|
^\s*BW\s+${BW_RAW}
|
||||||
|
^\s*MTU\s+${MTU}\s+bytes
|
||||||
^\s*Rate\s+period\s*:\s*${RATE_PERIOD}\s*s -> Rates
|
^\s*Rate\s+period\s*:\s*${RATE_PERIOD}\s*s -> Rates
|
||||||
^\s*$$
|
^\s*$$
|
||||||
^.* -> Start
|
^.* -> Start
|
||||||
|
|
@ -20,11 +27,13 @@ Rates
|
||||||
^\s*Input\s*:\s*${INPUT_BPS}\s*bit/s
|
^\s*Input\s*:\s*${INPUT_BPS}\s*bit/s
|
||||||
^\s*Output\s*:\s*${OUTPUT_BPS}\s*bit/s
|
^\s*Output\s*:\s*${OUTPUT_BPS}\s*bit/s
|
||||||
^\s*Peak\s+rate: -> AfterPeak
|
^\s*Peak\s+rate: -> AfterPeak
|
||||||
^\s*Intf\s+utilization:\s*input\s*${IN_UTIL}%\s+output\s*${OUT_UTIL}% -> Record
|
^\s*Intf\s+utilization:\s*input\s*${IN_UTIL}%\s+output\s*${OUT_UTIL}% -> Record Start
|
||||||
|
^${IFNAME}\s+is\s+${ADMIN_STATE}(?:,\s*ifindex:\s*${IFINDEX})? -> Clearall Start
|
||||||
^\s*$$
|
^\s*$$
|
||||||
^.* -> Rates
|
^.* -> Rates
|
||||||
|
|
||||||
AfterPeak
|
AfterPeak
|
||||||
^\s*Intf\s+utilization:\s*input\s*${IN_UTIL}%\s+output\s*${OUT_UTIL}% -> Record
|
^\s*Intf\s+utilization:\s*input\s*${IN_UTIL}%\s+output\s*${OUT_UTIL}% -> Record Start
|
||||||
|
^${IFNAME}\s+is\s+${ADMIN_STATE}(?:,\s*ifindex:\s*${IFINDEX})? -> Clearall Start
|
||||||
^\s*$$
|
^\s*$$
|
||||||
^.* -> AfterPeak
|
^.* -> AfterPeak
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
Value FLAGS (\S*)
|
||||||
|
Value DEST (\S+)
|
||||||
|
Value GATEWAY (\S+)
|
||||||
|
Value INTERFACE (\S+)
|
||||||
|
Value OWNER (\S+)
|
||||||
|
Value PRI (\S+)
|
||||||
|
Value METRIC (\S+)
|
||||||
|
|
||||||
|
Start
|
||||||
|
^\s*Dest\s+Gw -> Entries
|
||||||
|
^Headers:
|
||||||
|
^Codes
|
||||||
|
^Status\s+codes
|
||||||
|
^\s*$$
|
||||||
|
^.* -> Start
|
||||||
|
|
||||||
|
Entries
|
||||||
|
^\s*${FLAGS}\s*${DEST}\s+${GATEWAY}\s+${INTERFACE}\s+${OWNER}\s+${PRI}\s+${METRIC}\s*$$ -> Record
|
||||||
|
^\s*${DEST}\s+${GATEWAY}\s+${INTERFACE}\s+${OWNER}\s+${PRI}\s+${METRIC}\s*$$ -> Record
|
||||||
|
^\s*$$
|
||||||
|
^\S+\s*#\s*$$ -> End
|
||||||
|
^.*#\s*$$ -> End
|
||||||
|
^.* -> Entries
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
Value Required NAME (\S+)
|
Value Required NAME (\S+)
|
||||||
Value RD (\S+)
|
Value RD (\S+)
|
||||||
Value INTERFACES (.*)
|
Value PROTOCOLS (\S+)
|
||||||
|
Value VRF_ID (\d+)
|
||||||
|
|
||||||
Start
|
Start
|
||||||
^Name\s+Default\s+RD -> Records
|
^Name\s+Default\s+RD -> Records
|
||||||
|
|
@ -10,7 +11,8 @@ Start
|
||||||
^.* -> Start
|
^.* -> Start
|
||||||
|
|
||||||
Records
|
Records
|
||||||
^${NAME}\s+${RD}\s+${INTERFACES}\s*$$ -> Record
|
^${NAME}\s+${RD}\s+${PROTOCOLS}\s+${VRF_ID}\s*$$ -> Record
|
||||||
|
^${NAME}\s+${RD}\s+${PROTOCOLS}\s*$$ -> Record
|
||||||
^${NAME}\s+${RD}\s*$$ -> Record
|
^${NAME}\s+${RD}\s*$$ -> Record
|
||||||
^${NAME}\s*$$ -> Record
|
^${NAME}\s*$$ -> Record
|
||||||
^-+
|
^-+
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
Value DEST (\S+)
|
||||||
|
Value OWNER (\S+)
|
||||||
|
Value PRI (\S+)
|
||||||
|
Value METRIC (\S+)
|
||||||
|
Value FLAGS (\S*)
|
||||||
|
Value GATEWAY (\S+)
|
||||||
|
Value INTERFACE (\S+)
|
||||||
|
|
||||||
|
Start
|
||||||
|
^Headers:
|
||||||
|
^Codes
|
||||||
|
^Flag\s+codes
|
||||||
|
^\s*Dest -> Entries
|
||||||
|
^\s*$$
|
||||||
|
^.* -> Start
|
||||||
|
|
||||||
|
Entries
|
||||||
|
^\s*${DEST}\s+${OWNER}\s+${PRI}\s+${METRIC}\s+${FLAGS}\s*$$ -> Record
|
||||||
|
^\s+${GATEWAY},${INTERFACE}\s*$$ -> Record
|
||||||
|
^\s*$$
|
||||||
|
^\S+\s*#\s*$$ -> End
|
||||||
|
^.*#\s*$$ -> End
|
||||||
|
^.* -> Entries
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
Value PW_NAME (\S+)
|
||||||
|
Value PEER (\S+)
|
||||||
|
Value FEC (\S+)
|
||||||
|
Value PW_TYPE (\S+)
|
||||||
|
Value STATE (\S+)
|
||||||
|
Value LLABEL (\S+)
|
||||||
|
Value RLABEL (\S+)
|
||||||
|
Value VPN_OWNER (\S+)
|
||||||
|
|
||||||
|
Start
|
||||||
|
^PWName\s+PeerIP -> Entries
|
||||||
|
^PW\s+count
|
||||||
|
^Headers:
|
||||||
|
^Codes
|
||||||
|
^\s*$$
|
||||||
|
^.* -> Start
|
||||||
|
|
||||||
|
Entries
|
||||||
|
^${PW_NAME}\s+${PEER}\s+${FEC}\s+${PW_TYPE}\s+${STATE}\s+${LLABEL}\s+${RLABEL}\s+${VPN_OWNER}\s*$$ -> Record
|
||||||
|
^\s*$$
|
||||||
|
^\S+\s*#\s*$$ -> End
|
||||||
|
^.*#\s*$$ -> End
|
||||||
|
^.* -> Entries
|
||||||
19
netx_api/cli_templates/zte/zte_zxros_show_mac_l2vpn.textfsm
Normal file
19
netx_api/cli_templates/zte/zte_zxros_show_mac_l2vpn.textfsm
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
Value MAC (\S+)
|
||||||
|
Value VPN (\S+)
|
||||||
|
Value VLAN (\S+)
|
||||||
|
Value OUTGOING (.*)
|
||||||
|
Value ATTRIBUTE (\S+)
|
||||||
|
|
||||||
|
Start
|
||||||
|
^MAC\s+VPN -> Entries
|
||||||
|
^Total\s+MAC
|
||||||
|
^Headers:
|
||||||
|
^\s*$$
|
||||||
|
^.* -> Start
|
||||||
|
|
||||||
|
Entries
|
||||||
|
^${MAC}\s+${VPN}\s+${VLAN}\s+${OUTGOING}\s+${ATTRIBUTE}\s*$$ -> Record
|
||||||
|
^\s*$$
|
||||||
|
^\S+\s*#\s*$$ -> End
|
||||||
|
^.*#\s*$$ -> End
|
||||||
|
^.* -> Entries
|
||||||
|
|
@ -0,0 +1,18 @@
|
||||||
|
Value INTERFACE (\S+)
|
||||||
|
Value OPTIC_TYPE (\S+)
|
||||||
|
Value WAVELENGTH (\S+)
|
||||||
|
Value RX_POWER (\S+)
|
||||||
|
Value TX_POWER (\S+)
|
||||||
|
Value STATUS (\S+)
|
||||||
|
|
||||||
|
Start
|
||||||
|
^Interface\s+Type -> Entries
|
||||||
|
^\s*$$
|
||||||
|
^.* -> Start
|
||||||
|
|
||||||
|
Entries
|
||||||
|
^${INTERFACE}\s+${OPTIC_TYPE}\s+${WAVELENGTH}\s+${RX_POWER}\s+${TX_POWER}\s+${STATUS}(?:\s+\S+)?\s*$$ -> Record
|
||||||
|
^\s*$$
|
||||||
|
^\S+\s*#\s*$$ -> End
|
||||||
|
^.*#\s*$$ -> End
|
||||||
|
^.* -> Entries
|
||||||
|
|
@ -0,0 +1,23 @@
|
||||||
|
Value Filldown PROCESS_ID (\d+)
|
||||||
|
Value Required NEIGHBOR_ID (\S+)
|
||||||
|
Value PRI (\S+)
|
||||||
|
Value STATE (\S+)
|
||||||
|
Value DEAD_TIME (\S+)
|
||||||
|
Value ADDRESS (\S+)
|
||||||
|
Value INTERFACE (\S+)
|
||||||
|
|
||||||
|
Start
|
||||||
|
^\s*OSPF\s+Router\s+with\s+ID\s+\S+\s+\(Process\s+ID\s+${PROCESS_ID}\) -> Neighbors
|
||||||
|
^Neighbor\s+ID -> Neighbors
|
||||||
|
^\s*$$
|
||||||
|
^.* -> Start
|
||||||
|
|
||||||
|
Neighbors
|
||||||
|
^\s*OSPF\s+Router\s+with\s+ID\s+\S+\s+\(Process\s+ID\s+${PROCESS_ID}\) -> Neighbors
|
||||||
|
^Neighbor\s+ID
|
||||||
|
^Codes:
|
||||||
|
^${NEIGHBOR_ID}\s+${PRI}\s+${STATE}\s+${DEAD_TIME}\s+${ADDRESS}\s+${INTERFACE}\s*$$ -> Record
|
||||||
|
^\s*$$
|
||||||
|
^\S+\s*#\s*$$ -> End
|
||||||
|
^.*#\s*$$ -> End
|
||||||
|
^.* -> Neighbors
|
||||||
21
netx_api/cli_templates/zte/zte_zxros_show_vrrp_brief.textfsm
Normal file
21
netx_api/cli_templates/zte/zte_zxros_show_vrrp_brief.textfsm
Normal file
|
|
@ -0,0 +1,21 @@
|
||||||
|
Value INTERFACE (\S+)
|
||||||
|
Value VR_ID (\S+)
|
||||||
|
Value PRIORITY (\S+)
|
||||||
|
Value TIME (\S+)
|
||||||
|
Value FLAGS (\S*)
|
||||||
|
Value STATE (\S+)
|
||||||
|
Value MASTER_ADDR (\S+)
|
||||||
|
Value VROUTER_ADDR (\S+)
|
||||||
|
|
||||||
|
Start
|
||||||
|
^Interface\s+vrID -> Entries
|
||||||
|
^\s*$$
|
||||||
|
^.* -> Start
|
||||||
|
|
||||||
|
Entries
|
||||||
|
^${INTERFACE}\s+${VR_ID}\s+${PRIORITY}\s+${TIME}\s+${FLAGS}\s+${STATE}\s+${MASTER_ADDR}\s+${VROUTER_ADDR}\s*$$ -> Record
|
||||||
|
^${INTERFACE}\s+${VR_ID}\s+${PRIORITY}\s+${TIME}\s+${STATE}\s+${MASTER_ADDR}\s+${VROUTER_ADDR}\s*$$ -> Record
|
||||||
|
^\s*$$
|
||||||
|
^\S+\s*#\s*$$ -> End
|
||||||
|
^.*#\s*$$ -> End
|
||||||
|
^.* -> Entries
|
||||||
|
|
@ -14,20 +14,40 @@ from ._types import JsonType as _JsonType
|
||||||
|
|
||||||
|
|
||||||
class BizMigrationProject(Base):
|
class BizMigrationProject(Base):
|
||||||
"""Cutover project linking old/new collect tasks + baselines."""
|
"""Cutover project linking old/new portrait + HF collect tasks + baselines.
|
||||||
|
|
||||||
|
``old_task_id`` / ``new_task_id`` = portrait (biz-monitor) tasks — baseline source.
|
||||||
|
``old_hf_task_id`` / ``new_hf_task_id`` = cutover high-freq tasks — current source.
|
||||||
|
"""
|
||||||
|
|
||||||
__tablename__ = "biz_migration_project"
|
__tablename__ = "biz_migration_project"
|
||||||
|
|
||||||
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex)
|
||||||
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
name: Mapped[str] = mapped_column(String(256), default="", index=True)
|
||||||
|
# Portrait / biz-monitor tasks (baseline batches come from these)
|
||||||
old_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
old_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
new_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
new_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
|
# Cutover high-freq tasks (current batches for evaluate)
|
||||||
|
old_hf_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
|
new_hf_task_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
old_baseline_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
old_baseline_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
new_baseline_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
new_baseline_batch_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
# Reuse biz_port_mapping (before_if=old, after_if=new)
|
# Reuse biz_port_mapping (before_if=old, after_if=new)
|
||||||
mapping_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
mapping_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
# BizMonitorTemplate — HOW (via compare template) + dual/status rules
|
# BizMonitorTemplate — HOW (via compare template) + dual/status rules
|
||||||
monitor_template_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
monitor_template_id: Mapped[str] = mapped_column(String(64), default="", index=True)
|
||||||
|
# Project-level collect override (empty = use monitor template)
|
||||||
|
collect_metric_ids_json: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||||
|
# Per-metric interval overrides: {"interface_brief": 60, "bgp_peer": 300}
|
||||||
|
metric_interval_sec_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||||
|
# HF bindings (may be multiple tasks when intervals differ):
|
||||||
|
# [{"task_id":"...","metric_ids":["interface_brief"],"interval_sec":60}, ...]
|
||||||
|
old_hf_bindings_json: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||||
|
new_hf_bindings_json: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||||
|
# HF schedule window (project-level)
|
||||||
|
hf_interval_sec: Mapped[int] = mapped_column(Integer, default=60)
|
||||||
|
hf_start_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
hf_end_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|active|done
|
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|active|done
|
||||||
note: Mapped[str] = mapped_column(String(512), default="")
|
note: Mapped[str] = mapped_column(String(512), default="")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
||||||
|
|
|
||||||
|
|
@ -30,6 +30,8 @@ class BizStateTask(Base):
|
||||||
vendor: Mapped[str] = mapped_column(String(64), default="")
|
vendor: Mapped[str] = mapped_column(String(64), default="")
|
||||||
device_type: Mapped[str] = mapped_column(String(64), default="")
|
device_type: Mapped[str] = mapped_column(String(64), default="")
|
||||||
note: Mapped[str] = mapped_column(String(256), default="")
|
note: Mapped[str] = mapped_column(String(256), default="")
|
||||||
|
# "" | portrait | cutover_hf — filter cutover HF in biz-state list
|
||||||
|
purpose: Mapped[str] = mapped_column(String(32), default="", index=True)
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|running|paused|stopped
|
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|running|paused|stopped
|
||||||
interval_sec: Mapped[int] = mapped_column(Integer, default=300)
|
interval_sec: Mapped[int] = mapped_column(Integer, default=300)
|
||||||
# Keep snapshots for N calendar days (protected baselines/refs never auto-deleted)
|
# Keep snapshots for N calendar days (protected baselines/refs never auto-deleted)
|
||||||
|
|
@ -223,6 +225,8 @@ class BizCompareTemplate(Base):
|
||||||
compare_fields: Mapped[list] = mapped_column(_JsonType, default=list)
|
compare_fields: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||||
ignore_fields: Mapped[list] = mapped_column(_JsonType, default=list)
|
ignore_fields: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||||
metrics_json: Mapped[list] = mapped_column(_JsonType, default=list)
|
metrics_json: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||||
|
# Template-owned type aliases: [{"from":"GE","to":"gei"}, ...]
|
||||||
|
iface_normalize_json: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||||
note: Mapped[str] = mapped_column(String(512), default="")
|
note: Mapped[str] = mapped_column(String(512), default="")
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||||
|
|
|
||||||
499
tests/test_biz_migration_hf_decouple.py
Normal file
499
tests/test_biz_migration_hf_decouple.py
Normal file
|
|
@ -0,0 +1,499 @@
|
||||||
|
"""Integration: portrait vs cutover-HF task slot decoupling."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
|
||||||
|
from netx_api.db import Base
|
||||||
|
from netx_api.models import (
|
||||||
|
BizMigrationProject,
|
||||||
|
BizMonitorTemplate,
|
||||||
|
BizStateBatch,
|
||||||
|
BizStateTask,
|
||||||
|
)
|
||||||
|
from netx_api.biz_migration import service as mig
|
||||||
|
from netx_api.biz_state import service as biz_svc
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_catalog(*_a, **_k):
|
||||||
|
return {
|
||||||
|
"source_profile_id": "zte.interface_brief",
|
||||||
|
"kind": "catalog",
|
||||||
|
"enabled": True,
|
||||||
|
"title": "IF",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _stub_ne_meta(*_a, **_k):
|
||||||
|
return {
|
||||||
|
"ne_name": "ne",
|
||||||
|
"ne_ip": "10.0.0.1",
|
||||||
|
"vendor": "zte",
|
||||||
|
"device_type": "router",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class HfDecoupleFlowTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
engine = create_engine("sqlite+pysqlite:///:memory:", future=True)
|
||||||
|
TestingSession = sessionmaker(
|
||||||
|
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
self.db = TestingSession()
|
||||||
|
|
||||||
|
self.mt = BizMonitorTemplate(
|
||||||
|
id="mt1",
|
||||||
|
name="port",
|
||||||
|
compare_template_id="",
|
||||||
|
collect_metric_ids_json=["interface_brief"],
|
||||||
|
defaults_json={},
|
||||||
|
sheet_overrides_json=[],
|
||||||
|
)
|
||||||
|
self.db.add(self.mt)
|
||||||
|
|
||||||
|
self.old_portrait = self._mk_task("old_p", ne_id="ne-old", note="画像全量", purpose="")
|
||||||
|
self.new_portrait = self._mk_task("new_p", ne_id="ne-new", note="画像全量", purpose="")
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def _mk_task(
|
||||||
|
self,
|
||||||
|
tid: str,
|
||||||
|
*,
|
||||||
|
ne_id: str,
|
||||||
|
note: str = "",
|
||||||
|
purpose: str = "",
|
||||||
|
interval_sec: int = 3600,
|
||||||
|
vendor: str = "zte",
|
||||||
|
device_type: str = "router",
|
||||||
|
) -> BizStateTask:
|
||||||
|
t = BizStateTask(
|
||||||
|
id=tid,
|
||||||
|
source="managed",
|
||||||
|
ne_id=ne_id,
|
||||||
|
ne_name=ne_id,
|
||||||
|
ne_ip="10.0.0.1",
|
||||||
|
vendor=vendor,
|
||||||
|
device_type=device_type,
|
||||||
|
note=note,
|
||||||
|
purpose=purpose,
|
||||||
|
status="running",
|
||||||
|
interval_sec=interval_sec,
|
||||||
|
retention_days=30,
|
||||||
|
)
|
||||||
|
self.db.add(t)
|
||||||
|
return t
|
||||||
|
|
||||||
|
def _mk_batch(self, bid: str, task_id: str) -> BizStateBatch:
|
||||||
|
now = datetime.utcnow()
|
||||||
|
b = BizStateBatch(
|
||||||
|
id=bid,
|
||||||
|
task_id=task_id,
|
||||||
|
status="success",
|
||||||
|
started_at=now,
|
||||||
|
ended_at=now,
|
||||||
|
row_count=1,
|
||||||
|
)
|
||||||
|
self.db.add(b)
|
||||||
|
self.db.commit()
|
||||||
|
return b
|
||||||
|
|
||||||
|
def _create_project(self, **extra) -> dict:
|
||||||
|
body = {
|
||||||
|
"name": "cutover-night-1",
|
||||||
|
"old_task_id": self.old_portrait.id,
|
||||||
|
"new_task_id": self.new_portrait.id,
|
||||||
|
"monitor_template_id": self.mt.id,
|
||||||
|
"collect_now": False,
|
||||||
|
**extra,
|
||||||
|
}
|
||||||
|
with mock.patch("netx_api.biz_state.collect_runner.dispatch_collect"), mock.patch.object(
|
||||||
|
mig, "_catalog_item_for_metric", side_effect=_stub_catalog
|
||||||
|
), mock.patch.object(biz_svc, "_ne_meta", side_effect=_stub_ne_meta):
|
||||||
|
return mig.create_project(self.db, body)
|
||||||
|
|
||||||
|
def _ensure(self, pid: str, **kw):
|
||||||
|
with mock.patch("netx_api.biz_state.collect_runner.dispatch_collect"), mock.patch.object(
|
||||||
|
mig, "_catalog_item_for_metric", side_effect=_stub_catalog
|
||||||
|
), mock.patch.object(biz_svc, "_ne_meta", side_effect=_stub_ne_meta):
|
||||||
|
return mig.ensure_highfreq(self.db, pid, collect_now=False, **kw)
|
||||||
|
|
||||||
|
def test_create_keeps_portrait_slots(self) -> None:
|
||||||
|
out = self._create_project()
|
||||||
|
self.assertEqual(out["old_task_id"], "old_p")
|
||||||
|
self.assertEqual(out["new_task_id"], "new_p")
|
||||||
|
# One-shot create always spawns HF
|
||||||
|
self.assertTrue(out["old_hf_task_id"])
|
||||||
|
self.assertTrue(out["new_hf_task_id"])
|
||||||
|
self.assertNotEqual(out["old_hf_task_id"], "old_p")
|
||||||
|
self.assertNotEqual(out["new_hf_task_id"], "new_p")
|
||||||
|
|
||||||
|
def test_ensure_highfreq_does_not_overwrite_portrait(self) -> None:
|
||||||
|
proj = self._create_project()
|
||||||
|
res = self._ensure(proj["id"], interval_sec=60)
|
||||||
|
self.assertEqual(res["project"]["old_task_id"], "old_p")
|
||||||
|
self.assertEqual(res["project"]["new_task_id"], "new_p")
|
||||||
|
self.assertTrue(res["project"]["old_hf_task_id"])
|
||||||
|
self.assertNotEqual(res["project"]["old_hf_task_id"], "old_p")
|
||||||
|
old_hf = self.db.get(BizStateTask, res["project"]["old_hf_task_id"])
|
||||||
|
assert old_hf is not None
|
||||||
|
self.assertEqual(old_hf.purpose, mig.PURPOSE_CUTOVER_HF)
|
||||||
|
self.assertEqual(old_hf.interval_sec, 60)
|
||||||
|
old_p = self.db.get(BizStateTask, "old_p")
|
||||||
|
assert old_p is not None
|
||||||
|
self.assertEqual(old_p.note, "画像全量")
|
||||||
|
|
||||||
|
def test_evaluate_current_from_hf_not_portrait(self) -> None:
|
||||||
|
proj = self._create_project()
|
||||||
|
pid = proj["id"]
|
||||||
|
old_hf_id = proj["old_hf_task_id"]
|
||||||
|
new_hf_id = proj["new_hf_task_id"]
|
||||||
|
self._mk_batch("bl_old", "old_p")
|
||||||
|
self._mk_batch("bl_new", "new_p")
|
||||||
|
self._mk_batch("cur_old", old_hf_id)
|
||||||
|
self._mk_batch("cur_new", new_hf_id)
|
||||||
|
self._mk_batch("portrait_cur_old", "old_p")
|
||||||
|
self._mk_batch("portrait_cur_new", "new_p")
|
||||||
|
mig.patch_project(
|
||||||
|
self.db,
|
||||||
|
pid,
|
||||||
|
{"old_baseline_batch_id": "bl_old", "new_baseline_batch_id": "bl_new"},
|
||||||
|
)
|
||||||
|
batch = mig.create_batch(
|
||||||
|
self.db, pid, {"batch_label": "n1", "expect_set": {"ports": ["gei-0/1"]}}
|
||||||
|
)
|
||||||
|
with mock.patch.object(mig, "resolve_evaluate_sheets", return_value=([], [], {}, [])):
|
||||||
|
out = mig.run_evaluate(self.db, batch_id=batch["id"])
|
||||||
|
self.assertEqual(out["old_batch_id"], "cur_old")
|
||||||
|
self.assertEqual(out["new_batch_id"], "cur_new")
|
||||||
|
|
||||||
|
def test_collect_now_requires_hf_not_portrait(self) -> None:
|
||||||
|
# Project without HF: build manually (create always spawns HF now)
|
||||||
|
p = BizMigrationProject(
|
||||||
|
id="nohf",
|
||||||
|
name="nohf",
|
||||||
|
old_task_id=self.old_portrait.id,
|
||||||
|
new_task_id=self.new_portrait.id,
|
||||||
|
monitor_template_id=self.mt.id,
|
||||||
|
)
|
||||||
|
self.db.add(p)
|
||||||
|
self.db.commit()
|
||||||
|
with mock.patch("netx_api.biz_state.collect_runner.dispatch_collect") as disp:
|
||||||
|
out = mig.collect_project_now(self.db, p.id)
|
||||||
|
disp.assert_not_called()
|
||||||
|
old = out["old"]
|
||||||
|
new = out["new"]
|
||||||
|
if isinstance(old, list):
|
||||||
|
self.assertEqual(old[0].get("error"), "hf_task_missing")
|
||||||
|
else:
|
||||||
|
self.assertEqual(old.get("error"), "hf_task_missing")
|
||||||
|
if isinstance(new, list):
|
||||||
|
self.assertEqual(new[0].get("error"), "hf_task_missing")
|
||||||
|
else:
|
||||||
|
self.assertEqual(new.get("error"), "hf_task_missing")
|
||||||
|
|
||||||
|
def test_collect_now_dispatches_hf_only(self) -> None:
|
||||||
|
proj = self._create_project()
|
||||||
|
with mock.patch("netx_api.biz_state.collect_runner.dispatch_collect") as disp:
|
||||||
|
out = mig.collect_project_now(self.db, proj["id"])
|
||||||
|
self.assertTrue(any(x.get("ok") for x in out["old"]))
|
||||||
|
self.assertTrue(any(x.get("ok") for x in out["new"]))
|
||||||
|
called = {c.args[0] for c in disp.call_args_list}
|
||||||
|
self.assertEqual(
|
||||||
|
called,
|
||||||
|
{proj["old_hf_task_id"], proj["new_hf_task_id"]},
|
||||||
|
)
|
||||||
|
self.assertNotIn("old_p", called)
|
||||||
|
|
||||||
|
def test_project_collect_override(self) -> None:
|
||||||
|
proj = self._create_project()
|
||||||
|
mig.patch_project(self.db, proj["id"], {"collect_metric_ids": ["interface_brief"]})
|
||||||
|
p = self.db.get(BizMigrationProject, proj["id"])
|
||||||
|
assert p is not None
|
||||||
|
self.assertEqual(mig.resolve_collect_metric_ids(self.db, p), ["interface_brief"])
|
||||||
|
|
||||||
|
def test_empty_monitor_collect_expands_to_sheets(self) -> None:
|
||||||
|
self.mt.collect_metric_ids_json = []
|
||||||
|
self.db.commit()
|
||||||
|
sheets = [
|
||||||
|
{"metric_id": "interface_brief", "key_fields": ["if_name"]},
|
||||||
|
{"metric_id": "arp", "key_fields": ["ip"]},
|
||||||
|
{"metric_id": "bgp_summary", "key_fields": ["peer"]},
|
||||||
|
]
|
||||||
|
with mock.patch.object(
|
||||||
|
mig, "resolve_evaluate_sheets", return_value=(sheets, [], {}, [])
|
||||||
|
):
|
||||||
|
proj = self._create_project(collect_metric_ids=[])
|
||||||
|
p = self.db.get(BizMigrationProject, proj["id"])
|
||||||
|
assert p is not None
|
||||||
|
self.assertEqual(
|
||||||
|
mig.resolve_collect_metric_ids(self.db, p),
|
||||||
|
["interface_brief", "arp", "bgp_summary"],
|
||||||
|
)
|
||||||
|
self.assertEqual(
|
||||||
|
proj.get("collect_metric_ids_effective"),
|
||||||
|
["interface_brief", "arp", "bgp_summary"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_legacy_migrate_moves_hf_out_of_portrait_slot(self) -> None:
|
||||||
|
hf = self._mk_task(
|
||||||
|
"legacy_hf",
|
||||||
|
ne_id=self.old_portrait.ne_id,
|
||||||
|
note="割接高频/interface_brief/x",
|
||||||
|
purpose=mig.PURPOSE_CUTOVER_HF,
|
||||||
|
interval_sec=60,
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
p = BizMigrationProject(
|
||||||
|
id="leg1",
|
||||||
|
name="legacy",
|
||||||
|
old_task_id=hf.id,
|
||||||
|
new_task_id=self.new_portrait.id,
|
||||||
|
monitor_template_id=self.mt.id,
|
||||||
|
)
|
||||||
|
self.db.add(p)
|
||||||
|
self.db.commit()
|
||||||
|
mig.migrate_project_hf_slots(self.db, p, commit=True)
|
||||||
|
self.db.refresh(p)
|
||||||
|
self.assertEqual(p.old_hf_task_id, "legacy_hf")
|
||||||
|
self.assertEqual(p.old_task_id, "old_p")
|
||||||
|
|
||||||
|
def test_hf_window_paused_when_project_done(self) -> None:
|
||||||
|
out = self._create_project()
|
||||||
|
mig.patch_project(self.db, out["id"], {"status": "done"})
|
||||||
|
p = self.db.get(BizMigrationProject, out["id"])
|
||||||
|
assert p is not None
|
||||||
|
self.assertEqual(mig._hf_window_status(p), "paused")
|
||||||
|
old_hf = self.db.get(BizStateTask, out["old_hf_task_id"])
|
||||||
|
assert old_hf is not None
|
||||||
|
self.assertEqual(old_hf.status, "paused")
|
||||||
|
|
||||||
|
def test_hf_window_paused_when_ended(self) -> None:
|
||||||
|
proj = self._create_project(
|
||||||
|
hf_end_at=(datetime.utcnow() - timedelta(hours=1)).isoformat() + "Z",
|
||||||
|
)
|
||||||
|
p = self.db.get(BizMigrationProject, proj["id"])
|
||||||
|
assert p is not None
|
||||||
|
self.assertEqual(mig._hf_window_status(p), "paused")
|
||||||
|
self.assertTrue(str(proj.get("hf_end_at") or "").endswith("Z"))
|
||||||
|
|
||||||
|
def test_ensure_pauses_orphan_interval_tasks(self) -> None:
|
||||||
|
proj = self._create_project(
|
||||||
|
collect_metric_ids=["interface_brief", "arp"],
|
||||||
|
metric_interval_sec={"arp": 120},
|
||||||
|
hf_interval_sec=60,
|
||||||
|
)
|
||||||
|
bindings = proj.get("old_hf_bindings") or []
|
||||||
|
self.assertGreaterEqual(len(bindings), 2)
|
||||||
|
orphan_id = next(b["task_id"] for b in bindings if int(b["interval_sec"]) == 120)
|
||||||
|
# Shrink to single interval group
|
||||||
|
mig.patch_project(
|
||||||
|
self.db,
|
||||||
|
proj["id"],
|
||||||
|
{"collect_metric_ids": ["interface_brief"], "metric_interval_sec": {}},
|
||||||
|
)
|
||||||
|
self._ensure(proj["id"], interval_sec=60)
|
||||||
|
orphan = self.db.get(BizStateTask, orphan_id)
|
||||||
|
assert orphan is not None
|
||||||
|
self.assertEqual(orphan.status, "paused")
|
||||||
|
|
||||||
|
def test_parse_dt_offset_to_naive_utc(self) -> None:
|
||||||
|
# +08:00 wall 16:00 → UTC 08:00
|
||||||
|
dt = mig._parse_dt("2026-09-19T16:00:00+08:00")
|
||||||
|
assert dt is not None
|
||||||
|
self.assertEqual(dt.tzinfo, None)
|
||||||
|
self.assertEqual(dt.hour, 8)
|
||||||
|
self.assertEqual(mig._dt_iso(dt), "2026-09-19T08:00:00Z")
|
||||||
|
# Z suffix
|
||||||
|
dt2 = mig._parse_dt("2026-09-19T08:00:00Z")
|
||||||
|
assert dt2 is not None
|
||||||
|
self.assertEqual(dt2, dt)
|
||||||
|
|
||||||
|
def test_list_tasks_purpose_filter(self) -> None:
|
||||||
|
self._mk_task("hf1", ne_id="ne-x", purpose=mig.PURPOSE_CUTOVER_HF, note="割接高频/x")
|
||||||
|
self.db.commit()
|
||||||
|
portrait = biz_svc.list_tasks(self.db, purpose="portrait")
|
||||||
|
hf = biz_svc.list_tasks(self.db, purpose=mig.PURPOSE_CUTOVER_HF)
|
||||||
|
self.assertTrue(any(x["id"] == "hf1" for x in hf))
|
||||||
|
self.assertFalse(any(x["id"] == "hf1" for x in portrait))
|
||||||
|
self.assertTrue(any(x["id"] == "old_p" for x in portrait))
|
||||||
|
|
||||||
|
def test_second_ensure_reuses_hf_slot(self) -> None:
|
||||||
|
proj = self._create_project()
|
||||||
|
r1 = self._ensure(proj["id"])
|
||||||
|
r2 = self._ensure(proj["id"])
|
||||||
|
self.assertEqual(r1["project"]["old_hf_task_id"], r2["project"]["old_hf_task_id"])
|
||||||
|
self.assertFalse(r2["old_created"])
|
||||||
|
self.assertFalse(r2["new_created"])
|
||||||
|
|
||||||
|
def test_create_from_ne_spawns_hf(self) -> None:
|
||||||
|
out = self._create_project(
|
||||||
|
old_task_id="",
|
||||||
|
new_task_id="",
|
||||||
|
old_ne={"source": "managed", "ne_id": "ne-old"},
|
||||||
|
new_ne={"source": "managed", "ne_id": "ne-new"},
|
||||||
|
)
|
||||||
|
self.assertTrue(out["old_hf_task_id"])
|
||||||
|
self.assertTrue(out["new_hf_task_id"])
|
||||||
|
old_hf = self.db.get(BizStateTask, out["old_hf_task_id"])
|
||||||
|
assert old_hf is not None
|
||||||
|
self.assertEqual(old_hf.purpose, mig.PURPOSE_CUTOVER_HF)
|
||||||
|
self.assertEqual(old_hf.ne_id, "ne-old")
|
||||||
|
|
||||||
|
def test_metric_intervals_split_hf_bindings(self) -> None:
|
||||||
|
out = self._create_project(
|
||||||
|
collect_metric_ids=["interface_brief", "arp"],
|
||||||
|
metric_interval_sec={"arp": 120},
|
||||||
|
hf_interval_sec=60,
|
||||||
|
)
|
||||||
|
bindings = out.get("old_hf_bindings") or []
|
||||||
|
self.assertGreaterEqual(len(bindings), 2)
|
||||||
|
intervals = sorted(int(b["interval_sec"]) for b in bindings)
|
||||||
|
self.assertEqual(intervals, [60, 120])
|
||||||
|
task_ids = {b["task_id"] for b in bindings}
|
||||||
|
self.assertEqual(len(task_ids), 2)
|
||||||
|
|
||||||
|
def test_pin_batch_only_applies_to_matching_hf_task(self) -> None:
|
||||||
|
proj = self._create_project(
|
||||||
|
collect_metric_ids=["interface_brief", "arp"],
|
||||||
|
metric_interval_sec={"arp": 120},
|
||||||
|
hf_interval_sec=60,
|
||||||
|
)
|
||||||
|
bindings = proj.get("old_hf_bindings") or []
|
||||||
|
tid_60 = next(b["task_id"] for b in bindings if int(b["interval_sec"]) == 60)
|
||||||
|
tid_120 = next(b["task_id"] for b in bindings if int(b["interval_sec"]) == 120)
|
||||||
|
self._mk_batch("pin60", tid_60)
|
||||||
|
self._mk_batch("cur120", tid_120)
|
||||||
|
p = self.db.get(BizMigrationProject, proj["id"])
|
||||||
|
assert p is not None
|
||||||
|
# Pin points at 60s task; arp (120s) must still use its own latest batch
|
||||||
|
got_if = mig._current_batch_for_metric(
|
||||||
|
self.db, p, "old", "interface_brief", pinned_batch_id="pin60"
|
||||||
|
)
|
||||||
|
got_arp = mig._current_batch_for_metric(
|
||||||
|
self.db, p, "old", "arp", pinned_batch_id="pin60"
|
||||||
|
)
|
||||||
|
assert got_if is not None and got_arp is not None
|
||||||
|
self.assertEqual(got_if.id, "pin60")
|
||||||
|
self.assertEqual(got_arp.id, "cur120")
|
||||||
|
|
||||||
|
def test_catalog_rejects_placeholder_metrics(self) -> None:
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
fake = SimpleNamespace(
|
||||||
|
metric_id="bgp_vrf",
|
||||||
|
kind="collect",
|
||||||
|
profile_id="zte.bgp_vrf",
|
||||||
|
title="BGP VRF",
|
||||||
|
placeholders=[SimpleNamespace(name="vrf")],
|
||||||
|
)
|
||||||
|
with mock.patch(
|
||||||
|
"netx_api.biz_state.profiles.profiles_for_vendor", return_value=[fake]
|
||||||
|
), mock.patch(
|
||||||
|
"netx_api.lldp_shared.resolve_vendor_key", return_value="zte"
|
||||||
|
):
|
||||||
|
with self.assertRaises(Exception) as cm:
|
||||||
|
mig._catalog_item_for_metric(
|
||||||
|
vendor="zte", device_type="router", metric_id="bgp_vrf"
|
||||||
|
)
|
||||||
|
detail = getattr(cm.exception, "detail", str(cm.exception))
|
||||||
|
self.assertIn("metric_needs_bindings:bgp_vrf", str(detail))
|
||||||
|
|
||||||
|
def test_evaluate_marks_current_missing_no_cross_task_fallback(self) -> None:
|
||||||
|
proj = self._create_project(
|
||||||
|
collect_metric_ids=["interface_brief", "arp"],
|
||||||
|
metric_interval_sec={"arp": 120},
|
||||||
|
hf_interval_sec=60,
|
||||||
|
)
|
||||||
|
pid = proj["id"]
|
||||||
|
bindings = proj.get("old_hf_bindings") or []
|
||||||
|
tid_60 = next(b["task_id"] for b in bindings if int(b["interval_sec"]) == 60)
|
||||||
|
new_bindings = proj.get("new_hf_bindings") or []
|
||||||
|
new_60 = next(b["task_id"] for b in new_bindings if int(b["interval_sec"]) == 60)
|
||||||
|
self._mk_batch("bl_old", "old_p")
|
||||||
|
self._mk_batch("bl_new", "new_p")
|
||||||
|
self._mk_batch("cur_old_60", tid_60)
|
||||||
|
self._mk_batch("cur_new_60", new_60)
|
||||||
|
# deliberately no batch on 120s tasks
|
||||||
|
mig.patch_project(
|
||||||
|
self.db,
|
||||||
|
pid,
|
||||||
|
{"old_baseline_batch_id": "bl_old", "new_baseline_batch_id": "bl_new"},
|
||||||
|
)
|
||||||
|
batch = mig.create_batch(
|
||||||
|
self.db, pid, {"batch_label": "n1", "expect_set": {"ports": ["gei-0/1"]}}
|
||||||
|
)
|
||||||
|
sheets = [
|
||||||
|
{"metric_id": "interface_brief", "key_fields": ["interface"], "sheet_id": "if"},
|
||||||
|
{"metric_id": "arp", "key_fields": ["ip"], "sheet_id": "arp"},
|
||||||
|
]
|
||||||
|
with mock.patch.object(
|
||||||
|
mig, "resolve_evaluate_sheets", return_value=(sheets, [], {}, [])
|
||||||
|
), mock.patch.object(mig, "evaluate_metric_dual") as ev:
|
||||||
|
ev.return_value = {
|
||||||
|
"progress_ok": 0,
|
||||||
|
"progress_total": 0,
|
||||||
|
"anomaly": 0,
|
||||||
|
"anomaly_in_expect": 0,
|
||||||
|
"old_summary": {},
|
||||||
|
"new_summary": {},
|
||||||
|
"rows": [],
|
||||||
|
"new_baseline_mode": "provided",
|
||||||
|
"new_baseline_missing": False,
|
||||||
|
}
|
||||||
|
out = mig.run_evaluate(self.db, batch_id=batch["id"])
|
||||||
|
cards = (out.get("summary") or {}).get("sheet_cards") or []
|
||||||
|
by_mid = {c["metric_id"]: c for c in cards}
|
||||||
|
self.assertFalse(by_mid["interface_brief"].get("current_missing"))
|
||||||
|
self.assertTrue(by_mid["arp"].get("current_missing"))
|
||||||
|
self.assertIn("arp", " ".join((out.get("summary") or {}).get("current_missing_metrics") or []))
|
||||||
|
|
||||||
|
def test_baseline_expect_filters_to_collect_metrics(self) -> None:
|
||||||
|
proj = self._create_project(collect_metric_ids=["interface_brief"])
|
||||||
|
self._mk_batch("bl_old2", "old_p")
|
||||||
|
mig.patch_project(self.db, proj["id"], {"old_baseline_batch_id": "bl_old2"})
|
||||||
|
sheets = [
|
||||||
|
{"metric_id": "interface_brief", "key_fields": ["interface"], "sheet_id": "if"},
|
||||||
|
{"metric_id": "arp", "key_fields": ["ip"], "sheet_id": "arp"},
|
||||||
|
]
|
||||||
|
with mock.patch.object(
|
||||||
|
mig, "resolve_evaluate_sheets", return_value=(sheets, [], {}, [])
|
||||||
|
), mock.patch.object(mig, "_load_metric_rows", return_value=[]):
|
||||||
|
out = mig.list_baseline_expect_objects(self.db, proj["id"])
|
||||||
|
mids = [s["metric_id"] for s in out.get("sheets") or []]
|
||||||
|
self.assertEqual(mids, ["interface_brief"])
|
||||||
|
|
||||||
|
def test_project_to_dict_is_readonly(self) -> None:
|
||||||
|
hf = self._mk_task(
|
||||||
|
"legacy_hf2",
|
||||||
|
ne_id=self.old_portrait.ne_id,
|
||||||
|
note="割接高频/x",
|
||||||
|
purpose=mig.PURPOSE_CUTOVER_HF,
|
||||||
|
)
|
||||||
|
self.db.commit()
|
||||||
|
p = BizMigrationProject(
|
||||||
|
id="ro1",
|
||||||
|
name="ro",
|
||||||
|
old_task_id=hf.id,
|
||||||
|
new_task_id=self.new_portrait.id,
|
||||||
|
monitor_template_id=self.mt.id,
|
||||||
|
)
|
||||||
|
self.db.add(p)
|
||||||
|
self.db.commit()
|
||||||
|
d = mig.project_to_dict(self.db, p)
|
||||||
|
self.assertEqual(d["old_task_id"], "legacy_hf2")
|
||||||
|
self.assertEqual(d["old_hf_task_id"], "")
|
||||||
|
self.db.refresh(p)
|
||||||
|
self.assertEqual(p.old_task_id, "legacy_hf2")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import copy
|
||||||
import unittest
|
import unittest
|
||||||
|
|
||||||
from netx_api.biz_state.compare_engine import compare_rows, mapping_stats
|
from netx_api.biz_state.compare_engine import compare_rows, mapping_stats
|
||||||
|
|
@ -118,6 +119,26 @@ class CompareEngineTests(unittest.TestCase):
|
||||||
self.assertEqual(out["summary"]["removed"], 0)
|
self.assertEqual(out["summary"]["removed"], 0)
|
||||||
self.assertTrue(out["mapping_stats"].get("ignore_port_changes"))
|
self.assertTrue(out["mapping_stats"].get("ignore_port_changes"))
|
||||||
|
|
||||||
|
def test_empty_port_map_keeps_iface_when_required_for_uniqueness(self) -> None:
|
||||||
|
"""OSPF/VRRP-style: same neighbor_id on many interfaces must not collapse."""
|
||||||
|
before = [
|
||||||
|
{"process_id": "1", "neighbor_id": "2.2.2.2", "interface": "sg1", "address": "10.0.0.1"},
|
||||||
|
{"process_id": "1", "neighbor_id": "2.2.2.2", "interface": "sg2", "address": "10.0.0.2"},
|
||||||
|
]
|
||||||
|
after = copy.deepcopy(before)
|
||||||
|
out = compare_rows(
|
||||||
|
before_rows=before,
|
||||||
|
after_rows=after,
|
||||||
|
key_fields=["process_id", "neighbor_id", "interface"],
|
||||||
|
iface_fields=["interface"],
|
||||||
|
compare_fields=["address"],
|
||||||
|
port_map={},
|
||||||
|
)
|
||||||
|
self.assertEqual(out["summary"]["unchanged"], 2)
|
||||||
|
self.assertEqual(out["summary"]["changed"], 0)
|
||||||
|
self.assertIn("interface", out["summary"]["match_key_fields"])
|
||||||
|
self.assertFalse(out["mapping_stats"].get("ignore_port_changes"))
|
||||||
|
|
||||||
def test_unchanged_rows_are_listed(self) -> None:
|
def test_unchanged_rows_are_listed(self) -> None:
|
||||||
before = [{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"}]
|
before = [{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"}]
|
||||||
after = [{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"}]
|
after = [{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"}]
|
||||||
|
|
@ -331,13 +352,46 @@ class CompareSheetDefaultsTests(unittest.TestCase):
|
||||||
self.assertIn("bgp_peer.vpnv6", ids)
|
self.assertIn("bgp_peer.vpnv6", ids)
|
||||||
self.assertIn("isis_adjacency.ipv4", ids)
|
self.assertIn("isis_adjacency.ipv4", ids)
|
||||||
self.assertIn("isis_adjacency.ipv6", ids)
|
self.assertIn("isis_adjacency.ipv6", ids)
|
||||||
# Same source metric may appear multiple times
|
# Same source metric may appear multiple times (afi splits)
|
||||||
self.assertEqual(sum(1 for s in sheets if s["metric_id"] == "bgp_peer"), 4)
|
self.assertEqual(sum(1 for s in sheets if s["metric_id"] == "bgp_peer"), 6)
|
||||||
|
self.assertIn("bgp_peer.evpn", ids)
|
||||||
|
self.assertIn("bgp_peer.vpls", ids)
|
||||||
|
self.assertIn("vrrp.ipv4", ids)
|
||||||
|
self.assertIn("lldp_neighbor", ids)
|
||||||
|
detail = next(s for s in sheets if sheet_key(s) == "interface_detail")
|
||||||
|
self.assertEqual(detail["compare_fields"], ["admin"])
|
||||||
|
self.assertIn("input_bps", detail["display_fields"])
|
||||||
|
optical = next(s for s in sheets if sheet_key(s) == "optical_brief")
|
||||||
|
self.assertEqual(optical["compare_fields"], ["status"])
|
||||||
|
self.assertIn("rx_power", optical["display_fields"])
|
||||||
|
bgp4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.ipv4")
|
||||||
|
self.assertEqual(bgp4["compare_fields"], ["as_num", "state"])
|
||||||
|
self.assertIn("pfx_rcd", bgp4["display_fields"])
|
||||||
vpnv4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.vpnv4")
|
vpnv4 = next(s for s in sheets if sheet_key(s) == "bgp_peer.vpnv4")
|
||||||
self.assertEqual(vpnv4["row_filters"], [{"field": "afi", "op": "eq", "value": "vpnv4"}])
|
self.assertEqual(vpnv4["row_filters"], [{"field": "afi", "op": "eq", "value": "vpnv4"}])
|
||||||
isis4 = next(s for s in sheets if sheet_key(s) == "isis_adjacency.ipv4")
|
isis4 = next(s for s in sheets if sheet_key(s) == "isis_adjacency.ipv4")
|
||||||
self.assertEqual(isis4["row_filters"][0]["op"], "contains")
|
self.assertEqual(isis4["row_filters"][0]["op"], "contains")
|
||||||
|
|
||||||
|
def test_duplicate_match_keys_are_reported(self) -> None:
|
||||||
|
before = [
|
||||||
|
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"},
|
||||||
|
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "9"},
|
||||||
|
]
|
||||||
|
after = [
|
||||||
|
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "1"},
|
||||||
|
{"local_if": "a", "remote_sys": "X", "remote_if": "1", "remote_ip": "2"},
|
||||||
|
]
|
||||||
|
out = compare_rows(
|
||||||
|
before_rows=before,
|
||||||
|
after_rows=after,
|
||||||
|
key_fields=["local_if", "remote_sys", "remote_if"],
|
||||||
|
iface_fields=["local_if"],
|
||||||
|
compare_fields=["remote_ip"],
|
||||||
|
port_map={"a": "a"},
|
||||||
|
)
|
||||||
|
self.assertEqual(out["summary"]["duplicate_keys_before"], 1)
|
||||||
|
self.assertEqual(out["summary"]["duplicate_keys_after"], 1)
|
||||||
|
|
||||||
def test_normalize_allows_duplicate_metric_with_distinct_sheet_id(self) -> None:
|
def test_normalize_allows_duplicate_metric_with_distinct_sheet_id(self) -> None:
|
||||||
from netx_api.biz_state.compare_service import _normalize_sheet, sheet_key
|
from netx_api.biz_state.compare_service import _normalize_sheet, sheet_key
|
||||||
|
|
||||||
|
|
|
||||||
80
tests/test_iface_normalize.py
Normal file
80
tests/test_iface_normalize.py
Normal file
|
|
@ -0,0 +1,80 @@
|
||||||
|
"""Unit tests for iface normalize + subinterface port-map inheritance."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from netx_api.biz_state.compare_engine import apply_port_map, compare_rows
|
||||||
|
from netx_api.biz_state.iface_normalize import (
|
||||||
|
default_zte_iface_normalize_rules,
|
||||||
|
normalize_iface_name,
|
||||||
|
normalize_iface_rules,
|
||||||
|
resolve_mapped_iface,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class IfaceNormalizeTests(unittest.TestCase):
|
||||||
|
def test_prefix_rules_longest_first(self) -> None:
|
||||||
|
rules = default_zte_iface_normalize_rules()
|
||||||
|
self.assertEqual(normalize_iface_name("GE1.100", rules), "gei1.100")
|
||||||
|
self.assertEqual(normalize_iface_name("SG11", rules), "smartgroup11")
|
||||||
|
self.assertEqual(normalize_iface_name("XGE-0/1/0/1", rules), "xgei-0/1/0/1")
|
||||||
|
self.assertEqual(normalize_iface_name("XXVGE-0/2/0/10", rules), "xxvgei-0/2/0/10")
|
||||||
|
self.assertEqual(normalize_iface_name("CGE0/4/1/3", rules), "cgei0/4/1/3")
|
||||||
|
|
||||||
|
def test_does_not_mangle_canonical(self) -> None:
|
||||||
|
rules = default_zte_iface_normalize_rules()
|
||||||
|
self.assertEqual(normalize_iface_name("gei-0/0/0/1", rules), "gei-0/0/0/1")
|
||||||
|
self.assertEqual(normalize_iface_name("smartgroup11", rules), "smartgroup11")
|
||||||
|
self.assertEqual(normalize_iface_name("xgei-0/3/1/20.219", rules), "xgei-0/3/1/20.219")
|
||||||
|
|
||||||
|
def test_parse_text_rules(self) -> None:
|
||||||
|
rules = normalize_iface_rules("GE,gei\n# comment\nSG\tsmartgroup\n")
|
||||||
|
self.assertEqual(rules[0]["from"], "GE")
|
||||||
|
self.assertEqual(rules[1]["from"], "SG")
|
||||||
|
|
||||||
|
|
||||||
|
class PortMapSubifTests(unittest.TestCase):
|
||||||
|
def test_exact_wins_over_parent(self) -> None:
|
||||||
|
pmap = {
|
||||||
|
"gei-0/0/0/1": "xgei-0/1/0/1",
|
||||||
|
"gei-0/0/0/1.100": "xgei-0/1/0/9.100",
|
||||||
|
}
|
||||||
|
self.assertEqual(resolve_mapped_iface("gei-0/0/0/1.100", pmap), "xgei-0/1/0/9.100")
|
||||||
|
self.assertEqual(resolve_mapped_iface("gei-0/0/0/1.200", pmap), "xgei-0/1/0/1.200")
|
||||||
|
|
||||||
|
def test_parent_inherits_suffix(self) -> None:
|
||||||
|
pmap = {"gei-0/0/0/1": "xgei-0/1/0/1"}
|
||||||
|
self.assertEqual(resolve_mapped_iface("gei-0/0/0/1", pmap), "xgei-0/1/0/1")
|
||||||
|
self.assertEqual(resolve_mapped_iface("gei-0/0/0/1.100", pmap), "xgei-0/1/0/1.100")
|
||||||
|
self.assertEqual(resolve_mapped_iface("gei-0/0/0/2.100", pmap), "gei-0/0/0/2.100")
|
||||||
|
|
||||||
|
def test_apply_port_map_row(self) -> None:
|
||||||
|
row = apply_port_map(
|
||||||
|
{"interface": "gei-0/0/0/1.55", "admin": "up"},
|
||||||
|
iface_fields=["interface"],
|
||||||
|
port_map={"gei-0/0/0/1": "xgei-0/1/0/1"},
|
||||||
|
)
|
||||||
|
self.assertEqual(row["interface"], "xgei-0/1/0/1.55")
|
||||||
|
|
||||||
|
|
||||||
|
class ComparePipelineTests(unittest.TestCase):
|
||||||
|
def test_normalize_then_map_matches(self) -> None:
|
||||||
|
before = [{"interface": "GE-0/0/0/1.100", "admin": "up"}]
|
||||||
|
after = [{"interface": "xgei-0/1/0/1.100", "admin": "up"}]
|
||||||
|
out = compare_rows(
|
||||||
|
before_rows=before,
|
||||||
|
after_rows=after,
|
||||||
|
key_fields=["interface"],
|
||||||
|
iface_fields=["interface"],
|
||||||
|
compare_fields=["admin"],
|
||||||
|
port_map={"gei-0/0/0/1": "xgei-0/1/0/1"},
|
||||||
|
iface_normalize_rules=default_zte_iface_normalize_rules(),
|
||||||
|
)
|
||||||
|
self.assertEqual(out["summary"]["unchanged"], 1)
|
||||||
|
self.assertEqual(out["summary"]["added"], 0)
|
||||||
|
self.assertEqual(out["summary"]["removed"], 0)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
417
tests/test_zte_config_intent.py
Normal file
417
tests/test_zte_config_intent.py
Normal file
|
|
@ -0,0 +1,417 @@
|
||||||
|
"""Synthetic tests for ZTE config-intent parsers (no live secrets / host data)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from netx_api.biz_state.command_match import match_command
|
||||||
|
from netx_api.biz_state.compare_service import _default_zte_config_sheets, sheet_key
|
||||||
|
from netx_api.biz_state.parsers.zte import (
|
||||||
|
normalize_config_bgp_peer,
|
||||||
|
normalize_config_interface,
|
||||||
|
normalize_config_isis,
|
||||||
|
normalize_config_l2vpn_pw,
|
||||||
|
normalize_config_ospf,
|
||||||
|
normalize_config_static_route,
|
||||||
|
normalize_config_vrf,
|
||||||
|
)
|
||||||
|
from netx_api.biz_state.parsers.zte.config_bgp_peer import _is_ip_neighbor
|
||||||
|
|
||||||
|
from netx_api.biz_state.profiles import get_profile, metric_field_map, reload_profiles
|
||||||
|
|
||||||
|
|
||||||
|
_CFG_VRF = """
|
||||||
|
!<vrf>
|
||||||
|
ip vrf CUST_A
|
||||||
|
rd 100:1
|
||||||
|
description "Customer A"
|
||||||
|
address-family ipv4
|
||||||
|
route-target export 100:1
|
||||||
|
route-target import 100:1
|
||||||
|
$
|
||||||
|
address-family ipv6
|
||||||
|
route-target export 100:2
|
||||||
|
$
|
||||||
|
$
|
||||||
|
ip vrf CUST_B
|
||||||
|
rd 200:2
|
||||||
|
address-family ipv4
|
||||||
|
route-target import 200:2
|
||||||
|
$
|
||||||
|
$
|
||||||
|
!</vrf>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CFG_IF = """
|
||||||
|
!<if-intf>
|
||||||
|
interface gei-0/0/0/1
|
||||||
|
description uplink
|
||||||
|
ip vrf forwarding CUST_A
|
||||||
|
ip address 10.0.0.1 255.255.255.0
|
||||||
|
ipv6 address 2001:db8::1/64
|
||||||
|
mtu 9000
|
||||||
|
$
|
||||||
|
interface gei-0/0/0/2
|
||||||
|
shutdown
|
||||||
|
ip address 10.0.1.1 255.255.255.0
|
||||||
|
$
|
||||||
|
!</if-intf>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CFG_BGP = """
|
||||||
|
!<bgp>
|
||||||
|
router bgp 65000
|
||||||
|
neighbor 10.0.0.1 remote-as 65001
|
||||||
|
neighbor 10.0.0.1 update-source loopback1
|
||||||
|
neighbor 10.0.0.1 password cipher SKIPME
|
||||||
|
neighbor 10.0.0.1 route-map RM_IN in
|
||||||
|
neighbor 10.0.0.1 peer-group CORE_RR
|
||||||
|
neighbor CORE_RR peer-group
|
||||||
|
neighbor CORE_RR remote-as 65009
|
||||||
|
neighbor FC00:1::1 remote-as 65002
|
||||||
|
address-family vpnv4
|
||||||
|
neighbor 10.0.0.1 activate
|
||||||
|
neighbor 10.0.0.1 route-map RM_OUT out
|
||||||
|
neighbor CORE_RR activate
|
||||||
|
$
|
||||||
|
address-family ipv4 vrf CUST_A
|
||||||
|
neighbor 10.0.0.2 remote-as 65003
|
||||||
|
neighbor 10.0.0.2 activate
|
||||||
|
$
|
||||||
|
address-family l2vpn evpn
|
||||||
|
neighbor 10.0.0.1 activate
|
||||||
|
$
|
||||||
|
$
|
||||||
|
!</bgp>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CFG_L2VPN = """
|
||||||
|
!<l2vpn>
|
||||||
|
vpws VPN_A
|
||||||
|
description "pw site a"
|
||||||
|
access-point gei-0/0/0/1.100
|
||||||
|
pseudo-wire pw1
|
||||||
|
neighbour 10.0.0.1 vcid 1001
|
||||||
|
encapsulation mpls
|
||||||
|
$
|
||||||
|
$
|
||||||
|
$
|
||||||
|
vpls VPN_B
|
||||||
|
access-point smartgroup1
|
||||||
|
pseudo-wire pw2
|
||||||
|
neighbour 10.0.0.2 vcid 2002
|
||||||
|
encapsulation mpls
|
||||||
|
$
|
||||||
|
$
|
||||||
|
$
|
||||||
|
!</l2vpn>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CFG_STATIC_V4 = """
|
||||||
|
!<static>
|
||||||
|
ip route 0.0.0.0 0.0.0.0 10.0.0.1 name default_gw
|
||||||
|
ip route 10.9.9.0 255.255.255.0 null1
|
||||||
|
ip route 10.8.8.0 255.255.255.0 nexthop-vrf OTHER_VRF
|
||||||
|
ip route vrf CUST_A 10.1.1.0 255.255.255.0 bvi12.100 10.1.1.1
|
||||||
|
ip route vrf CUST_A 10.2.2.0 255.255.255.0 gei-0/0/0/1.10 10.2.2.1 bfd enable
|
||||||
|
ip route vrf CUST_B 10.3.3.0 255.255.255.0 null1 metric 255
|
||||||
|
ip route vrf CUST_A 10.4.4.0 255.255.255.0 smartgroup1.1 10.4.4.1 track LINK_A
|
||||||
|
!</static>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CFG_STATIC_V6 = """
|
||||||
|
!<ipv6-static-route>
|
||||||
|
ipv6 route ::/0 null1 track LINK_BFD
|
||||||
|
ipv6 route vrf CUST_A 2001:db8:1::/64 2001:db8:1::1
|
||||||
|
!</ipv6-static-route>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CFG_OSPF = """
|
||||||
|
!<ospfv2>
|
||||||
|
router ospf 3 vrf CUST_A
|
||||||
|
router-id 1.1.1.1
|
||||||
|
area 0.0.0.0
|
||||||
|
interface smartgroup1.10
|
||||||
|
cost 20
|
||||||
|
hello-interval 10
|
||||||
|
dead-interval 40
|
||||||
|
network point-to-point
|
||||||
|
bfd interval 50 min-rx 50
|
||||||
|
authentication message-digest
|
||||||
|
message-digest-key 1 md5 encrypted SKIPME
|
||||||
|
$
|
||||||
|
$
|
||||||
|
area 10.0.0.1
|
||||||
|
nssa default-information-originate no-summary
|
||||||
|
interface gei-0/0/0/1
|
||||||
|
cost 10
|
||||||
|
$
|
||||||
|
$
|
||||||
|
area 20.0.0.1
|
||||||
|
stub no-summary
|
||||||
|
$
|
||||||
|
redistribute static route-map RM1
|
||||||
|
redistribute connected
|
||||||
|
$
|
||||||
|
!</ospfv2>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CFG_OSPF_V3 = """
|
||||||
|
!<ospfv3>
|
||||||
|
ipv6 router ospf 120 vrf CORE
|
||||||
|
router-id 2.2.2.2
|
||||||
|
redistribute connected
|
||||||
|
area 0.0.0.0
|
||||||
|
interface vlan918
|
||||||
|
cost 10
|
||||||
|
network point-to-point
|
||||||
|
$
|
||||||
|
$
|
||||||
|
$
|
||||||
|
!</ospfv3>
|
||||||
|
"""
|
||||||
|
|
||||||
|
_CFG_ISIS = """
|
||||||
|
!<isis>
|
||||||
|
router isis 1
|
||||||
|
area 62.0047
|
||||||
|
system-id 1241.9509.6248
|
||||||
|
router-id 1.1.1.1
|
||||||
|
is-type level-2-only
|
||||||
|
authentication encrypted SKIPME
|
||||||
|
address-family ipv6
|
||||||
|
multi-topology
|
||||||
|
segment-routing srv6 locator MAIN
|
||||||
|
end-sid func-code ::1
|
||||||
|
$
|
||||||
|
$
|
||||||
|
interface loopback0
|
||||||
|
ipv6 router isis
|
||||||
|
passive-mode
|
||||||
|
$
|
||||||
|
interface smartgroup1
|
||||||
|
circuit-type level-2-only
|
||||||
|
ipv6 bfd-enable
|
||||||
|
ipv6 metric 5
|
||||||
|
ipv6 router isis
|
||||||
|
network point-to-point
|
||||||
|
hello-authentication encrypted SKIPME
|
||||||
|
bfd-enable
|
||||||
|
$
|
||||||
|
$
|
||||||
|
!</isis>
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ZteConfigIntentTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
reload_profiles()
|
||||||
|
|
||||||
|
def test_profiles_and_field_schema(self) -> None:
|
||||||
|
for pid, mid in (
|
||||||
|
("zte.config_vrf", "config_vrf"),
|
||||||
|
("zte.config_interface", "config_interface"),
|
||||||
|
("zte.config_bgp_peer", "config_bgp_peer"),
|
||||||
|
("zte.config_l2vpn_pw", "config_l2vpn_pw"),
|
||||||
|
("zte.config_static_route", "config_static_route"),
|
||||||
|
("zte.config_static_route_v6", "config_static_route"),
|
||||||
|
("zte.config_ospf", "config_ospf"),
|
||||||
|
("zte.config_ospf_v3", "config_ospf"),
|
||||||
|
("zte.config_isis", "config_isis"),
|
||||||
|
):
|
||||||
|
p = get_profile(pid)
|
||||||
|
self.assertIsNotNone(p)
|
||||||
|
assert p is not None
|
||||||
|
self.assertEqual(p.metric_id, mid)
|
||||||
|
self.assertTrue(p.enabled)
|
||||||
|
fields = metric_field_map().get(mid) or []
|
||||||
|
self.assertTrue(any(f.is_key for f in fields))
|
||||||
|
|
||||||
|
def test_if_intf_cli_prefers_config_interface(self) -> None:
|
||||||
|
hit = match_command(vendor_key="zte", command="show running-config if-intf")
|
||||||
|
self.assertIsNotNone(hit)
|
||||||
|
assert hit is not None
|
||||||
|
self.assertEqual(hit.profile.profile_id, "zte.config_interface")
|
||||||
|
|
||||||
|
def test_config_vrf(self) -> None:
|
||||||
|
rows = normalize_config_vrf(raw_text=_CFG_VRF, command="show running-config vrf")
|
||||||
|
by = {r["vrf_name"]: r for r in rows}
|
||||||
|
self.assertEqual(set(by), {"CUST_A", "CUST_B"})
|
||||||
|
self.assertEqual(by["CUST_A"]["rd"], "100:1")
|
||||||
|
self.assertIn("ipv4", by["CUST_A"]["address_families"])
|
||||||
|
self.assertIn("ipv6", by["CUST_A"]["address_families"])
|
||||||
|
self.assertIn("100:1", by["CUST_A"]["rt_export"])
|
||||||
|
self.assertIn("100:2", by["CUST_A"]["rt_export"])
|
||||||
|
self.assertEqual(by["CUST_A"]["description"], "Customer A")
|
||||||
|
self.assertIn("200:2", by["CUST_B"]["rt_import"])
|
||||||
|
|
||||||
|
def test_config_interface(self) -> None:
|
||||||
|
rows = normalize_config_interface(
|
||||||
|
raw_text=_CFG_IF, command="show running-config if-intf"
|
||||||
|
)
|
||||||
|
by = {r["interface"]: r for r in rows}
|
||||||
|
self.assertEqual(by["gei-0/0/0/1"]["vrf"], "CUST_A")
|
||||||
|
self.assertEqual(by["gei-0/0/0/1"]["admin"], "up")
|
||||||
|
self.assertIn("10.0.0.1", by["gei-0/0/0/1"]["ip_address"])
|
||||||
|
self.assertIn("2001:db8::1/64", by["gei-0/0/0/1"]["ipv6_address"])
|
||||||
|
self.assertEqual(by["gei-0/0/0/1"]["mtu"], "9000")
|
||||||
|
self.assertEqual(by["gei-0/0/0/2"]["admin"], "down")
|
||||||
|
|
||||||
|
def test_config_bgp_peer_skips_password(self) -> None:
|
||||||
|
rows = normalize_config_bgp_peer(
|
||||||
|
raw_text=_CFG_BGP, command="show running-config bgp"
|
||||||
|
)
|
||||||
|
blob = " ".join(str(v) for r in rows for v in r.values())
|
||||||
|
self.assertNotIn("SKIPME", blob)
|
||||||
|
self.assertNotIn("password", blob.lower())
|
||||||
|
|
||||||
|
by = {(r["afi"], r["vrf"], r["neighbor"], r["peer_group"]): r for r in rows}
|
||||||
|
self.assertIn(("vpnv4", "", "10.0.0.1", "CORE_RR"), by)
|
||||||
|
self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["remote_as"], "65001")
|
||||||
|
self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["activate"], "enable")
|
||||||
|
self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["update_source"], "loopback1")
|
||||||
|
self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["route_map_in"], "RM_IN")
|
||||||
|
self.assertEqual(by[("vpnv4", "", "10.0.0.1", "CORE_RR")]["route_map_out"], "RM_OUT")
|
||||||
|
# AF-scoped RM must not bleed into other address-families
|
||||||
|
self.assertEqual(by[("l2vpn-evpn", "", "10.0.0.1", "CORE_RR")]["route_map_in"], "RM_IN")
|
||||||
|
self.assertEqual(by[("l2vpn-evpn", "", "10.0.0.1", "CORE_RR")]["route_map_out"], "")
|
||||||
|
# peer-group name is not an IP → neighbor empty, peer_group set
|
||||||
|
self.assertIn(("vpnv4", "", "", "CORE_RR"), by)
|
||||||
|
self.assertEqual(by[("vpnv4", "", "", "CORE_RR")]["remote_as"], "65009")
|
||||||
|
self.assertEqual(by[("ipv4", "CUST_A", "10.0.0.2", "")]["remote_as"], "65003")
|
||||||
|
self.assertIn(("l2vpn-evpn", "", "10.0.0.1", "CORE_RR"), by)
|
||||||
|
# global IPv6 neighbor without AF activate
|
||||||
|
self.assertIn(("global", "", "FC00:1::1", ""), by)
|
||||||
|
# no name left in neighbor column
|
||||||
|
self.assertTrue(all((not r["neighbor"]) or _is_ip_neighbor(r["neighbor"]) for r in rows))
|
||||||
|
|
||||||
|
def test_config_l2vpn_pw(self) -> None:
|
||||||
|
rows = normalize_config_l2vpn_pw(
|
||||||
|
raw_text=_CFG_L2VPN, command="show running-config l2vpn"
|
||||||
|
)
|
||||||
|
by = {(r["vpn_name"], r["pw_name"]): r for r in rows}
|
||||||
|
self.assertEqual(by[("VPN_A", "pw1")]["peer"], "10.0.0.1")
|
||||||
|
self.assertEqual(by[("VPN_A", "pw1")]["vcid"], "1001")
|
||||||
|
self.assertEqual(by[("VPN_A", "pw1")]["vpn_type"], "vpws")
|
||||||
|
self.assertEqual(by[("VPN_A", "pw1")]["access_point"], "gei-0/0/0/1.100")
|
||||||
|
self.assertEqual(by[("VPN_B", "pw2")]["vpn_type"], "vpls")
|
||||||
|
self.assertEqual(by[("VPN_B", "pw2")]["vcid"], "2002")
|
||||||
|
|
||||||
|
def test_config_interface_merges_duplicate_blocks(self) -> None:
|
||||||
|
raw = """
|
||||||
|
!<if-intf>
|
||||||
|
interface te_tunnel36
|
||||||
|
description LSP
|
||||||
|
shutdown
|
||||||
|
$
|
||||||
|
interface te_tunnel36
|
||||||
|
ip unnumbered loopback0
|
||||||
|
$
|
||||||
|
!</if-intf>
|
||||||
|
"""
|
||||||
|
rows = normalize_config_interface(raw_text=raw, command="show running-config if-intf")
|
||||||
|
self.assertEqual(len(rows), 1)
|
||||||
|
self.assertEqual(rows[0]["admin"], "down")
|
||||||
|
self.assertEqual(rows[0]["description"], "LSP")
|
||||||
|
|
||||||
|
def test_config_l2vpn_backup_pw_not_overwrite(self) -> None:
|
||||||
|
raw = """
|
||||||
|
!<l2vpn>
|
||||||
|
vpws 18163
|
||||||
|
access-point gei-0/0/1/9.1751
|
||||||
|
pseudo-wire pw_primary
|
||||||
|
neighbour 10.0.0.1 vcid 18163
|
||||||
|
encapsulation raw
|
||||||
|
$
|
||||||
|
backup-pw pw_backup protect pw_primary
|
||||||
|
neighbour 10.0.0.2 vcid 18163
|
||||||
|
$
|
||||||
|
$
|
||||||
|
$
|
||||||
|
!</l2vpn>
|
||||||
|
"""
|
||||||
|
rows = normalize_config_l2vpn_pw(raw_text=raw, command="show running-config l2vpn")
|
||||||
|
by = {r["pw_name"]: r for r in rows}
|
||||||
|
self.assertEqual(by["pw_primary"]["peer"], "10.0.0.1")
|
||||||
|
self.assertEqual(by["pw_backup"]["peer"], "10.0.0.2")
|
||||||
|
self.assertEqual(by["pw_primary"]["vcid"], "18163")
|
||||||
|
|
||||||
|
def test_config_static_route_v4(self) -> None:
|
||||||
|
rows = normalize_config_static_route(
|
||||||
|
raw_text=_CFG_STATIC_V4, command="show running-config static"
|
||||||
|
)
|
||||||
|
self.assertTrue(all(r["af"] == "ipv4" for r in rows))
|
||||||
|
by = {(r["vrf"], r["prefix"], r["interface"], r["next_hop"]): r for r in rows}
|
||||||
|
self.assertEqual(by[("", "0.0.0.0", "", "10.0.0.1")]["route_name"], "default_gw")
|
||||||
|
self.assertEqual(by[("", "10.9.9.0", "null1", "")]["mask"], "255.255.255.0")
|
||||||
|
self.assertEqual(by[("", "10.8.8.0", "", "")]["nexthop_vrf"], "OTHER_VRF")
|
||||||
|
self.assertEqual(
|
||||||
|
by[("CUST_A", "10.1.1.0", "bvi12.100", "10.1.1.1")]["mask"], "255.255.255.0"
|
||||||
|
)
|
||||||
|
self.assertEqual(by[("CUST_A", "10.2.2.0", "gei-0/0/0/1.10", "10.2.2.1")]["bfd"], "enable")
|
||||||
|
self.assertEqual(by[("CUST_B", "10.3.3.0", "null1", "")]["metric"], "255")
|
||||||
|
self.assertEqual(by[("CUST_A", "10.4.4.0", "smartgroup1.1", "10.4.4.1")]["track"], "LINK_A")
|
||||||
|
|
||||||
|
def test_config_static_route_v6(self) -> None:
|
||||||
|
rows = normalize_config_static_route(
|
||||||
|
raw_text=_CFG_STATIC_V6, command="show running-config ipv6-static-route"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertTrue(all(r["af"] == "ipv6" for r in rows))
|
||||||
|
by = {(r["vrf"], r["prefix"]): r for r in rows}
|
||||||
|
self.assertEqual(by[("", "::/0")]["interface"], "null1")
|
||||||
|
self.assertEqual(by[("", "::/0")]["track"], "LINK_BFD")
|
||||||
|
self.assertEqual(by[("CUST_A", "2001:db8:1::/64")]["next_hop"], "2001:db8:1::1")
|
||||||
|
|
||||||
|
def test_compare_config_sheets(self) -> None:
|
||||||
|
sheets = _default_zte_config_sheets()
|
||||||
|
ids = [sheet_key(s) for s in sheets]
|
||||||
|
self.assertIn("config_vrf", ids)
|
||||||
|
self.assertIn("config_interface", ids)
|
||||||
|
self.assertIn("config_bgp_peer", ids)
|
||||||
|
self.assertIn("config_l2vpn_pw", ids)
|
||||||
|
self.assertIn("config_static_route.ipv4", ids)
|
||||||
|
self.assertIn("config_static_route.ipv6", ids)
|
||||||
|
self.assertIn("config_ospf.ipv4", ids)
|
||||||
|
self.assertIn("config_ospf.ipv6", ids)
|
||||||
|
self.assertIn("config_isis", ids)
|
||||||
|
for s in sheets:
|
||||||
|
self.assertTrue(s.get("key_fields"))
|
||||||
|
self.assertTrue(s.get("compare_fields") or s.get("key_fields"))
|
||||||
|
|
||||||
|
def test_config_ospf(self) -> None:
|
||||||
|
rows = normalize_config_ospf(raw_text=_CFG_OSPF, command="show running-config ospfv2")
|
||||||
|
blob = " ".join(str(v) for r in rows for v in r.values())
|
||||||
|
self.assertNotIn("SKIPME", blob)
|
||||||
|
by = {(r["area"], r["interface"]): r for r in rows}
|
||||||
|
self.assertEqual(by[("0.0.0.0", "smartgroup1.10")]["cost"], "20")
|
||||||
|
self.assertEqual(by[("0.0.0.0", "smartgroup1.10")]["network_type"], "point-to-point")
|
||||||
|
self.assertEqual(by[("0.0.0.0", "smartgroup1.10")]["bfd"], "enable")
|
||||||
|
self.assertEqual(by[("0.0.0.0", "smartgroup1.10")]["vrf"], "CUST_A")
|
||||||
|
self.assertIn("static", by[("0.0.0.0", "smartgroup1.10")]["redistribute"])
|
||||||
|
self.assertEqual(by[("10.0.0.1", "gei-0/0/0/1")]["area_type"], "nssa")
|
||||||
|
self.assertEqual(by[("20.0.0.1", "")]["area_type"], "stub")
|
||||||
|
v3 = normalize_config_ospf(raw_text=_CFG_OSPF_V3, command="show running-config ospfv3")
|
||||||
|
self.assertEqual(len(v3), 1)
|
||||||
|
self.assertEqual(v3[0]["af"], "ipv6")
|
||||||
|
self.assertEqual(v3[0]["interface"], "vlan918")
|
||||||
|
|
||||||
|
def test_config_isis(self) -> None:
|
||||||
|
rows = normalize_config_isis(raw_text=_CFG_ISIS, command="show running-config isis")
|
||||||
|
blob = " ".join(str(v) for r in rows for v in r.values())
|
||||||
|
self.assertNotIn("SKIPME", blob)
|
||||||
|
by = {r["interface"]: r for r in rows}
|
||||||
|
self.assertEqual(set(by), {"loopback0", "smartgroup1"})
|
||||||
|
self.assertEqual(by["loopback0"]["passive"], "yes")
|
||||||
|
self.assertEqual(by["loopback0"]["ipv6_enable"], "yes")
|
||||||
|
self.assertEqual(by["loopback0"]["area"], "62.0047")
|
||||||
|
self.assertEqual(by["smartgroup1"]["circuit_type"], "level-2-only")
|
||||||
|
self.assertEqual(by["smartgroup1"]["ipv6_metric"], "5")
|
||||||
|
self.assertEqual(by["smartgroup1"]["ipv6_bfd"], "enable")
|
||||||
|
self.assertEqual(by["smartgroup1"]["bfd"], "enable")
|
||||||
|
self.assertEqual(by["smartgroup1"]["network_type"], "point-to-point")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
248
tests/test_zte_config_real_dump.py
Normal file
248
tests/test_zte_config_real_dump.py
Normal file
|
|
@ -0,0 +1,248 @@
|
||||||
|
"""Regression: config-intent parsers vs real ``test/show-zte/show-config``.
|
||||||
|
|
||||||
|
Skipped when the dump is absent. Validates counts and field fidelity against
|
||||||
|
raw MIM section lines (not synthetic fixtures).
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
import unittest
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from netx_api.biz_state.parsers.zte.config_bgp_peer import normalize_config_bgp_peer
|
||||||
|
from netx_api.biz_state.parsers.zte.config_common import extract_mim_section
|
||||||
|
from netx_api.biz_state.parsers.zte.config_interface import normalize_config_interface
|
||||||
|
from netx_api.biz_state.parsers.zte.config_isis import normalize_config_isis
|
||||||
|
from netx_api.biz_state.parsers.zte.config_l2vpn_pw import normalize_config_l2vpn_pw
|
||||||
|
from netx_api.biz_state.parsers.zte.config_ospf import normalize_config_ospf
|
||||||
|
from netx_api.biz_state.parsers.zte.config_static_route import (
|
||||||
|
_V4_HEAD,
|
||||||
|
_parse_v4,
|
||||||
|
_parse_v4_rest,
|
||||||
|
normalize_config_static_route,
|
||||||
|
)
|
||||||
|
from netx_api.biz_state.parsers.zte.config_vrf import normalize_config_vrf
|
||||||
|
|
||||||
|
_CONFIG = Path(__file__).resolve().parents[2] / "test" / "show-zte" / "show-config"
|
||||||
|
|
||||||
|
|
||||||
|
@unittest.skipUnless(_CONFIG.is_file(), "test/show-zte/show-config not present")
|
||||||
|
class ZteConfigRealDumpTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
cls.text = _CONFIG.read_text(encoding="utf-8", errors="replace").replace("\r\n", "\n")
|
||||||
|
|
||||||
|
def test_vrf_count_and_rd(self) -> None:
|
||||||
|
body = extract_mim_section(self.text, "vrf")
|
||||||
|
raw = re.findall(r"(?im)^\s*ip\s+vrf\s+(\S+)\s*$", body)
|
||||||
|
rows = normalize_config_vrf(raw_text=self.text, command="show running-config vrf")
|
||||||
|
self.assertEqual(len(rows), len(raw))
|
||||||
|
by = {r["vrf_name"]: r for r in rows}
|
||||||
|
cur = None
|
||||||
|
expect_rd: dict[str, str] = {}
|
||||||
|
for line in body.splitlines():
|
||||||
|
m = re.match(r"(?i)^\s*ip\s+vrf\s+(\S+)\s*$", line)
|
||||||
|
if m:
|
||||||
|
cur = m.group(1)
|
||||||
|
continue
|
||||||
|
m = re.match(r"(?i)^\s*rd\s+(\S+)\s*$", line)
|
||||||
|
if m and cur:
|
||||||
|
expect_rd[cur] = m.group(1)
|
||||||
|
for name, rd in expect_rd.items():
|
||||||
|
self.assertEqual(by[name]["rd"], rd, msg=name)
|
||||||
|
|
||||||
|
def test_interface_merged_fields(self) -> None:
|
||||||
|
body = extract_mim_section(self.text, "if-intf")
|
||||||
|
rows = normalize_config_interface(
|
||||||
|
raw_text=self.text, command="show running-config if-intf"
|
||||||
|
)
|
||||||
|
by = {r["interface"]: r for r in rows}
|
||||||
|
exp: dict[str, dict] = {}
|
||||||
|
cur = None
|
||||||
|
block_ips: list[str] = []
|
||||||
|
block_ip6s: list[str] = []
|
||||||
|
block_admin = None
|
||||||
|
block_vrf = ""
|
||||||
|
block_mtu = ""
|
||||||
|
|
||||||
|
def commit(name: str) -> None:
|
||||||
|
nonlocal block_admin, block_vrf, block_mtu, block_ips, block_ip6s
|
||||||
|
e = exp[name]
|
||||||
|
if block_admin is not None:
|
||||||
|
e["admin"] = block_admin
|
||||||
|
if block_vrf:
|
||||||
|
e["vrf"] = block_vrf
|
||||||
|
if block_mtu:
|
||||||
|
e["mtu"] = block_mtu
|
||||||
|
for ip in block_ips:
|
||||||
|
if ip not in e["ips"]:
|
||||||
|
e["ips"].append(ip)
|
||||||
|
for ip in block_ip6s:
|
||||||
|
if ip not in e["ip6s"]:
|
||||||
|
e["ip6s"].append(ip)
|
||||||
|
|
||||||
|
for line in body.splitlines():
|
||||||
|
m = re.match(r"(?i)^\s*interface\s+(\S+)\s*$", line)
|
||||||
|
if m:
|
||||||
|
if cur:
|
||||||
|
commit(cur)
|
||||||
|
cur = m.group(1)
|
||||||
|
exp.setdefault(cur, {"vrf": "", "admin": "up", "ips": [], "ip6s": [], "mtu": ""})
|
||||||
|
block_ips, block_ip6s = [], []
|
||||||
|
block_admin, block_vrf, block_mtu = None, "", ""
|
||||||
|
continue
|
||||||
|
if not cur:
|
||||||
|
continue
|
||||||
|
if re.match(r"^\$\s*$", line):
|
||||||
|
commit(cur)
|
||||||
|
cur = None
|
||||||
|
continue
|
||||||
|
low = line.strip().lower()
|
||||||
|
if low == "shutdown":
|
||||||
|
block_admin = "down"
|
||||||
|
elif low == "no shutdown":
|
||||||
|
block_admin = "up"
|
||||||
|
m = re.match(r"(?i)^\s*ip\s+vrf\s+forwarding\s+(\S+)\s*$", line)
|
||||||
|
if m:
|
||||||
|
block_vrf = m.group(1)
|
||||||
|
continue
|
||||||
|
m = re.match(r"(?i)^\s*ip\s+address\s+(\S+)(?:\s+(\S+))?\s*$", line)
|
||||||
|
if m:
|
||||||
|
a, mask = m.group(1), m.group(2) or ""
|
||||||
|
block_ips.append(f"{a}/{mask}" if mask else a)
|
||||||
|
continue
|
||||||
|
m = re.match(r"(?i)^\s*ipv6\s+address\s+(\S+)\s*$", line)
|
||||||
|
if m:
|
||||||
|
block_ip6s.append(m.group(1))
|
||||||
|
continue
|
||||||
|
m = re.match(r"(?i)^\s*mtu\s+(\d+)\s*$", line)
|
||||||
|
if m:
|
||||||
|
block_mtu = m.group(1)
|
||||||
|
if cur:
|
||||||
|
commit(cur)
|
||||||
|
|
||||||
|
self.assertEqual(len(rows), len(exp))
|
||||||
|
for name, e in exp.items():
|
||||||
|
g = by[name]
|
||||||
|
self.assertEqual(g["vrf"], e["vrf"], msg=name)
|
||||||
|
self.assertEqual(g["admin"], e["admin"], msg=name)
|
||||||
|
self.assertEqual(
|
||||||
|
[x for x in (g["ip_address"] or "").split(",") if x],
|
||||||
|
e["ips"],
|
||||||
|
msg=name,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_static_route_line_coverage(self) -> None:
|
||||||
|
body = extract_mim_section(self.text, "static")
|
||||||
|
raw = [ln for ln in body.splitlines() if re.match(r"(?i)^\s*ip\s+route\s+", ln)]
|
||||||
|
rows = normalize_config_static_route(
|
||||||
|
raw_text=self.text, command="show running-config static"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(rows), len(raw))
|
||||||
|
self.assertEqual(rows, _parse_v4(body))
|
||||||
|
for ln in raw:
|
||||||
|
m = _V4_HEAD.match(ln)
|
||||||
|
self.assertIsNotNone(m, msg=ln[:120])
|
||||||
|
assert m is not None
|
||||||
|
extra = _parse_v4_rest(m.group("rest") or "")
|
||||||
|
hit = any(
|
||||||
|
r["vrf"] == (m.group("vrf") or "").strip()
|
||||||
|
and r["prefix"] == m.group("prefix")
|
||||||
|
and r["mask"] == m.group("mask")
|
||||||
|
and r["next_hop"] == extra["next_hop"]
|
||||||
|
and r["interface"] == extra["interface"]
|
||||||
|
and r["nexthop_vrf"] == extra["nexthop_vrf"]
|
||||||
|
for r in rows
|
||||||
|
)
|
||||||
|
self.assertTrue(hit, msg=ln[:140])
|
||||||
|
bfd_raw = sum(1 for ln in raw if re.search(r"(?i)\bbfd\b", ln))
|
||||||
|
self.assertEqual(sum(1 for r in rows if r["bfd"]), bfd_raw)
|
||||||
|
|
||||||
|
def test_static_route_v6(self) -> None:
|
||||||
|
body = extract_mim_section(self.text, "ipv6-static-route")
|
||||||
|
raw = [ln for ln in body.splitlines() if re.match(r"(?i)^\s*ipv6\s+route\s+", ln)]
|
||||||
|
rows = normalize_config_static_route(
|
||||||
|
raw_text=self.text, command="show running-config ipv6-static-route"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(rows), len(raw))
|
||||||
|
|
||||||
|
def test_bgp_activate_and_no_secrets(self) -> None:
|
||||||
|
body = extract_mim_section(self.text, "bgp")
|
||||||
|
activates = {
|
||||||
|
m.group(1)
|
||||||
|
for m in re.finditer(
|
||||||
|
r"(?im)^\s*neighbor\s+(\S+)\s+activate(?:\s+disable)?\s*$", body
|
||||||
|
)
|
||||||
|
}
|
||||||
|
rows = normalize_config_bgp_peer(
|
||||||
|
raw_text=self.text, command="show running-config bgp"
|
||||||
|
)
|
||||||
|
blob = " ".join(str(v) for r in rows for v in r.values()).lower()
|
||||||
|
self.assertNotIn("password", blob)
|
||||||
|
self.assertNotIn("cipher", blob)
|
||||||
|
# Every activate token appears as neighbor (IP) or peer_group (name)
|
||||||
|
covered = {r["neighbor"] for r in rows if r["neighbor"]} | {
|
||||||
|
r["peer_group"] for r in rows if r["peer_group"]
|
||||||
|
}
|
||||||
|
self.assertTrue(activates <= covered)
|
||||||
|
# neighbor column is IP-only
|
||||||
|
for r in rows:
|
||||||
|
if r["neighbor"]:
|
||||||
|
self.assertTrue(
|
||||||
|
re.match(r"^\d{1,3}(?:\.\d{1,3}){3}$", r["neighbor"])
|
||||||
|
or ":" in r["neighbor"],
|
||||||
|
msg=r["neighbor"],
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_l2vpn_neighbour_coverage(self) -> None:
|
||||||
|
body = extract_mim_section(self.text, "l2vpn")
|
||||||
|
neis = set(
|
||||||
|
re.findall(r"(?im)^\s*neighbour\s+(\S+)\s+vcid\s+(\S+)\s*$", body)
|
||||||
|
)
|
||||||
|
rows = normalize_config_l2vpn_pw(
|
||||||
|
raw_text=self.text, command="show running-config l2vpn"
|
||||||
|
)
|
||||||
|
parsed = {(r["peer"], r["vcid"]) for r in rows if r["peer"] and r["vcid"]}
|
||||||
|
self.assertEqual(parsed & neis, neis)
|
||||||
|
for r in rows:
|
||||||
|
if r["peer"] and r["vcid"]:
|
||||||
|
self.assertIn((r["peer"], r["vcid"]), neis)
|
||||||
|
|
||||||
|
def test_ospf_v2_interface_coverage(self) -> None:
|
||||||
|
body = extract_mim_section(self.text, "ospfv2")
|
||||||
|
procs = re.findall(
|
||||||
|
r"(?im)^\s*router\s+ospf\s+(\S+)(?:\s+vrf\s+(\S+))?\s*$", body
|
||||||
|
)
|
||||||
|
ifaces = re.findall(r"(?im)^\s*interface\s+(\S+)\s*$", body)
|
||||||
|
rows = normalize_config_ospf(
|
||||||
|
raw_text=self.text, command="show running-config ospfv2"
|
||||||
|
)
|
||||||
|
self.assertTrue(all(r["af"] == "ipv4" for r in rows))
|
||||||
|
parsed_if = {r["interface"] for r in rows if r["interface"]}
|
||||||
|
self.assertEqual(parsed_if, set(ifaces))
|
||||||
|
self.assertEqual(len({(r["process_id"], r["vrf"]) for r in rows}), len(procs))
|
||||||
|
blob = " ".join(str(v) for r in rows for v in r.values()).lower()
|
||||||
|
self.assertNotIn("encrypted", blob)
|
||||||
|
self.assertNotIn("message-digest-key", blob)
|
||||||
|
|
||||||
|
def test_ospf_v3_and_isis(self) -> None:
|
||||||
|
v3_body = extract_mim_section(self.text, "ospfv3")
|
||||||
|
v3_if = re.findall(r"(?im)^\s*interface\s+(\S+)\s*$", v3_body)
|
||||||
|
v3 = normalize_config_ospf(
|
||||||
|
raw_text=self.text, command="show running-config ospfv3"
|
||||||
|
)
|
||||||
|
self.assertEqual({r["interface"] for r in v3 if r["interface"]}, set(v3_if))
|
||||||
|
|
||||||
|
isis_body = extract_mim_section(self.text, "isis")
|
||||||
|
isis_if = re.findall(r"(?im)^\s*interface\s+(\S+)\s*$", isis_body)
|
||||||
|
rows = normalize_config_isis(
|
||||||
|
raw_text=self.text, command="show running-config isis"
|
||||||
|
)
|
||||||
|
self.assertEqual({r["interface"] for r in rows}, set(isis_if))
|
||||||
|
blob = " ".join(str(v) for r in rows for v in r.values()).lower()
|
||||||
|
self.assertNotIn("encrypted", blob)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
326
tests/test_zte_extended_parsers.py
Normal file
326
tests/test_zte_extended_parsers.py
Normal file
|
|
@ -0,0 +1,326 @@
|
||||||
|
"""Synthetic (desensitized) tests for extended ZTE biz_state parsers."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
|
||||||
|
from netx_api.biz_state.collect_session import resolve_aux_command
|
||||||
|
from netx_api.biz_state.command_match import expand_from_bindings, match_command
|
||||||
|
from netx_api.biz_state.enrich import apply_enrich_joins
|
||||||
|
from netx_api.biz_state.parsers.common.vrf_list import normalize_vrf_list
|
||||||
|
from netx_api.biz_state.parsers.zte import (
|
||||||
|
normalize_bgp_peer,
|
||||||
|
normalize_bgp_route,
|
||||||
|
normalize_ip_route,
|
||||||
|
normalize_ipv6_route,
|
||||||
|
normalize_l2vpn_pw,
|
||||||
|
normalize_optical_brief,
|
||||||
|
normalize_ospf_neighbor,
|
||||||
|
normalize_vrrp,
|
||||||
|
)
|
||||||
|
from netx_api.biz_state.profiles import AuxCommand, get_profile, metric_field_map, reload_profiles
|
||||||
|
from netx_api.ntc_parse import apply_rule
|
||||||
|
|
||||||
|
|
||||||
|
_VRF_SAMPLE = """
|
||||||
|
Name Default RD Protocols VRF ID
|
||||||
|
CUST_A 100:1 ipv4 1
|
||||||
|
CUST_B 200:2 ipv4,ipv6 2
|
||||||
|
mng <not set> ipv4,ipv6 3
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BGP_V4_SUMMARY = """
|
||||||
|
Neighbor Ver As MsgRcvd MsgSend Up/Down State/PfxRcd
|
||||||
|
10.0.0.1 4 65001 100 200 1w0d 5
|
||||||
|
10.0.0.2 4 65002 10 20 00:01:02 Connect
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BGP_V6_SUMMARY = """
|
||||||
|
Neighbor Ver As MsgRcvd MsgSend Up/Down State/PfxRcd
|
||||||
|
FC00:1::1
|
||||||
|
4 65001 100 200 1w0d 0
|
||||||
|
FC00:1::2
|
||||||
|
4 65002 10 20 2d3h 3
|
||||||
|
"""
|
||||||
|
|
||||||
|
_OSPF_SAMPLE = """
|
||||||
|
OSPF Router with ID (1.1.1.1) (Process ID 1)
|
||||||
|
|
||||||
|
Neighbor ID Pri State DeadTime Address Interface
|
||||||
|
2.2.2.2 1 FULL/-- 00:00:39 10.0.0.2 smartgroup1
|
||||||
|
3.3.3.3 1 FULL/DR 00:00:30 10.0.0.3 gei-0/0/0/1
|
||||||
|
|
||||||
|
OSPF Router with ID (1.1.1.1) (Process ID 20)
|
||||||
|
|
||||||
|
Neighbor ID Pri State DeadTime Address Interface
|
||||||
|
4.4.4.4 1 FULL/-- 00:00:40 10.1.0.2 smartgroup2
|
||||||
|
"""
|
||||||
|
|
||||||
|
_VRRP_SAMPLE = """
|
||||||
|
Interface vrID Pri Time A P L State Master addr VRouter addr
|
||||||
|
vlan10 10 110 1000 P Master 10.0.0.2 10.0.0.1
|
||||||
|
gei-0/0/0/1.100 100 90 1000 P Backup 10.0.1.2 10.0.1.1
|
||||||
|
smartgroup1.5 5 110 1000 P Init 0.0.0.0 10.0.2.1
|
||||||
|
"""
|
||||||
|
|
||||||
|
_OPTICAL_SAMPLE = """
|
||||||
|
Interface Type Wavelength RxPower(dBm) TxPower(dBm) Status Intensity(Rx)
|
||||||
|
gei-0/0/0/1 1G-10km-SFP 1310nm -6.0/[-20.0,-3.0] -5.9/[-9.0,-3.0] Normal Normal
|
||||||
|
cgei-0/1/0/1 100G-LR4 1310nm -2.1/[-8.0,2.0] -1.5/[-4.0,2.0] Normal Normal
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BGP_ROUTE_IN = """
|
||||||
|
Routes Learned From This Neighbor:
|
||||||
|
Network Next Hop Metric LocPrf RtPrf Path
|
||||||
|
* 10.1.0.0/24 10.0.0.1 20 65001 ?
|
||||||
|
* 10.2.0.0/24 10.0.0.1 20 65001 65009 ?
|
||||||
|
"""
|
||||||
|
|
||||||
|
_IP_ROUTE = """
|
||||||
|
Dest Gw Interface Owner Pri Metric
|
||||||
|
*> 0.0.0.0/0 10.0.0.1 smartgroup1 BGP 200 1
|
||||||
|
*> 10.1.0.0/24 10.0.0.2 gei-0/0/0/1 connected 0 0
|
||||||
|
"""
|
||||||
|
|
||||||
|
_IPV6_ROUTE = """
|
||||||
|
Dest Protocol Pri Metric Flag
|
||||||
|
Nexthop,Interface
|
||||||
|
2001:db8::/32 B 200 0
|
||||||
|
2001:db8:1::1,smartgroup1
|
||||||
|
fc00:1::/64 C 0 0
|
||||||
|
::,gei-0/0/0/1.10
|
||||||
|
"""
|
||||||
|
|
||||||
|
_L2VPN_PW = """
|
||||||
|
PWName PeerIP FEC PWType State Llabel Rlabel VPNOwner
|
||||||
|
pw1 10.0.0.1 128 Ethernet H UP 100 200 L:VPN_A
|
||||||
|
pw2 10.0.0.2 128 Ethernet S DOWN - - L:VPN_B
|
||||||
|
pw3 10.0.0.3 128 Ethernet UP 101 201 W:100
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class ZteExtendedParserTests(unittest.TestCase):
|
||||||
|
@classmethod
|
||||||
|
def setUpClass(cls) -> None:
|
||||||
|
reload_profiles()
|
||||||
|
|
||||||
|
def test_vrf_list_protocols(self) -> None:
|
||||||
|
rows = normalize_vrf_list(
|
||||||
|
raw_text=_VRF_SAMPLE,
|
||||||
|
vendor="zte",
|
||||||
|
device_type="zte_zxros",
|
||||||
|
command="show ip vrf",
|
||||||
|
)
|
||||||
|
by = {r["vrf_name"]: r for r in rows}
|
||||||
|
self.assertIn("CUST_A", by)
|
||||||
|
self.assertEqual(by["CUST_A"]["protocols"], "ipv4")
|
||||||
|
self.assertIn("ipv6", by["CUST_B"]["protocols"])
|
||||||
|
self.assertEqual(by["CUST_B"]["vrf_id"], "2")
|
||||||
|
|
||||||
|
def test_bgp_peer_ipv4_and_ipv6_wrap(self) -> None:
|
||||||
|
v4 = normalize_bgp_peer(
|
||||||
|
raw_text=_BGP_V4_SUMMARY, command="show bgp vpnv4 unicast summary"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(v4), 2)
|
||||||
|
self.assertEqual(v4[0]["afi"], "vpnv4")
|
||||||
|
self.assertEqual(v4[0]["state"], "Established")
|
||||||
|
self.assertEqual(v4[0]["pfx_rcd"], "5")
|
||||||
|
self.assertEqual(v4[1]["state"], "Connect")
|
||||||
|
|
||||||
|
v6 = normalize_bgp_peer(
|
||||||
|
raw_text=_BGP_V6_SUMMARY, command="show bgp vpnv6 unicast summary"
|
||||||
|
)
|
||||||
|
self.assertEqual(len(v6), 2)
|
||||||
|
self.assertTrue(all(r["afi"] == "vpnv6" for r in v6))
|
||||||
|
self.assertEqual(v6[0]["neighbor"].upper(), "FC00:1::1")
|
||||||
|
|
||||||
|
vrf = normalize_bgp_peer(
|
||||||
|
raw_text=_BGP_V4_SUMMARY,
|
||||||
|
command="show bgp vpnv4 unicast vrf CUST_A summary",
|
||||||
|
params={"vrf": "CUST_A"},
|
||||||
|
)
|
||||||
|
self.assertTrue(all(r["vrf"] == "CUST_A" for r in vrf))
|
||||||
|
|
||||||
|
def test_bgp_summary_fsm(self) -> None:
|
||||||
|
rows = apply_rule(
|
||||||
|
platform="zte_zxros",
|
||||||
|
rule_key="zte_zxros_show_bgp_summary",
|
||||||
|
text=_BGP_V4_SUMMARY,
|
||||||
|
command="show bgp vpnv4 unicast summary",
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(rows), 2)
|
||||||
|
|
||||||
|
def test_ospf_vrrp_optical(self) -> None:
|
||||||
|
ospf = normalize_ospf_neighbor(raw_text=_OSPF_SAMPLE, command="show ip ospf neighbor")
|
||||||
|
self.assertEqual(len(ospf), 3)
|
||||||
|
procs = {r["process_id"] for r in ospf}
|
||||||
|
self.assertEqual(procs, {"1", "20"})
|
||||||
|
|
||||||
|
vrrp = normalize_vrrp(raw_text=_VRRP_SAMPLE, command="show vrrp ipv4 brief")
|
||||||
|
self.assertEqual(len(vrrp), 3)
|
||||||
|
self.assertTrue(all(r["af"] == "ipv4" for r in vrrp))
|
||||||
|
states = {r["state"] for r in vrrp}
|
||||||
|
self.assertIn("Master", states)
|
||||||
|
|
||||||
|
opt = normalize_optical_brief(raw_text=_OPTICAL_SAMPLE, command="show opticalinfo brief")
|
||||||
|
self.assertEqual(len(opt), 2)
|
||||||
|
self.assertEqual(opt[0]["status"], "Normal")
|
||||||
|
|
||||||
|
def test_bgp_route_and_aux_render(self) -> None:
|
||||||
|
routes = normalize_bgp_route(
|
||||||
|
raw_text=_BGP_ROUTE_IN,
|
||||||
|
command="show bgp vpnv4 unicast vrf CUST_A neighbor in 10.0.0.1",
|
||||||
|
params={"vrf": "CUST_A", "neighbor": "10.0.0.1", "direction": "in"},
|
||||||
|
)
|
||||||
|
self.assertEqual(len(routes), 2)
|
||||||
|
self.assertEqual(routes[0]["network"], "10.1.0.0/24")
|
||||||
|
self.assertEqual(routes[0]["direction"], "in")
|
||||||
|
|
||||||
|
peers = normalize_bgp_peer(
|
||||||
|
raw_text=_BGP_V4_SUMMARY, command="show bgp vpnv4 unicast vrf CUST_A summary"
|
||||||
|
)
|
||||||
|
from netx_api.biz_state.enrich import EnrichJoin
|
||||||
|
|
||||||
|
apply_enrich_joins(
|
||||||
|
routes,
|
||||||
|
{"bgp_summary": peers},
|
||||||
|
[EnrichJoin(from_aux="bgp_summary", on="neighbor", take=("as_num", "state", "pfx_rcd"))],
|
||||||
|
)
|
||||||
|
self.assertEqual(routes[0]["as_num"], "65001")
|
||||||
|
self.assertEqual(routes[0]["state"], "Established")
|
||||||
|
|
||||||
|
ra = resolve_aux_command(
|
||||||
|
AuxCommand(key="bgp_summary", profile_id="zte.bgp_vpnv4_vrf_summary"),
|
||||||
|
params={"vrf": "CUST_A"},
|
||||||
|
)
|
||||||
|
self.assertEqual(ra.command, "show bgp vpnv4 unicast vrf CUST_A summary")
|
||||||
|
|
||||||
|
def test_ip_ipv6_route_and_pw(self) -> None:
|
||||||
|
ip = normalize_ip_route(
|
||||||
|
raw_text=_IP_ROUTE,
|
||||||
|
command="show ip forwarding route vrf CUST_A",
|
||||||
|
params={"vrf": "CUST_A"},
|
||||||
|
)
|
||||||
|
self.assertEqual(len(ip), 2)
|
||||||
|
self.assertEqual(ip[0]["dest"], "0.0.0.0/0")
|
||||||
|
self.assertEqual(ip[0]["vrf"], "CUST_A")
|
||||||
|
|
||||||
|
v6 = normalize_ipv6_route(
|
||||||
|
raw_text=_IPV6_ROUTE,
|
||||||
|
command="show ipv6 forwarding route vrf CUST_B",
|
||||||
|
params={"vrf": "CUST_B"},
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(v6), 1)
|
||||||
|
self.assertTrue(any(r["dest"].startswith("2001:db8") for r in v6))
|
||||||
|
|
||||||
|
pw = normalize_l2vpn_pw(raw_text=_L2VPN_PW, command="show l2vpn forwardinfo")
|
||||||
|
self.assertEqual(len(pw), 3)
|
||||||
|
self.assertEqual(pw[0]["state"].upper(), "UP")
|
||||||
|
self.assertEqual(pw[1]["state"].upper(), "DOWN")
|
||||||
|
|
||||||
|
def test_profiles_and_expand(self) -> None:
|
||||||
|
for mid in (
|
||||||
|
"ospf_neighbor",
|
||||||
|
"vrrp",
|
||||||
|
"optical_brief",
|
||||||
|
"bgp_route",
|
||||||
|
"ip_route",
|
||||||
|
"ipv6_route",
|
||||||
|
"l2vpn_pw",
|
||||||
|
"l2vpn_mac",
|
||||||
|
"evpn_mac",
|
||||||
|
"interface_detail",
|
||||||
|
):
|
||||||
|
self.assertIn(mid, metric_field_map())
|
||||||
|
|
||||||
|
self.assertIsNotNone(get_profile("zte.bgp_ipv6_summary"))
|
||||||
|
self.assertIsNotNone(get_profile("zte.bgp_vpnv4_vrf_summary"))
|
||||||
|
self.assertIsNotNone(get_profile("zte.ospf_neighbor"))
|
||||||
|
self.assertIsNotNone(get_profile("zte.interface_detail"))
|
||||||
|
self.assertIsNotNone(get_profile("zte.bgp_vpnv6_neighbor_in"))
|
||||||
|
self.assertIsNotNone(get_profile("zte.bgp_vpnv6_neighbor_out"))
|
||||||
|
|
||||||
|
p = get_profile("zte.bgp_vpnv4_vrf_neighbor_in")
|
||||||
|
assert p is not None
|
||||||
|
pairs = expand_from_bindings(
|
||||||
|
profile=p,
|
||||||
|
bindings=[{"vrf": "CUST_A", "neighbor": "10.0.0.1"}],
|
||||||
|
)
|
||||||
|
self.assertEqual(len(pairs), 1)
|
||||||
|
cmd, params = pairs[0]
|
||||||
|
self.assertIn("vrf CUST_A", cmd)
|
||||||
|
self.assertIn("10.0.0.1", cmd)
|
||||||
|
hit = match_command(vendor_key="zte", command=cmd)
|
||||||
|
self.assertIsNotNone(hit)
|
||||||
|
assert hit is not None
|
||||||
|
self.assertEqual(hit.params.get("vrf"), "CUST_A")
|
||||||
|
self.assertEqual(hit.params.get("neighbor"), "10.0.0.1")
|
||||||
|
|
||||||
|
def test_interface_detail_and_vpnv6_neighbor(self) -> None:
|
||||||
|
from netx_api.biz_state.parsers.zte import normalize_interface_detail
|
||||||
|
|
||||||
|
sample = """
|
||||||
|
gei-0/0/0/1 is up, ifindex: 100
|
||||||
|
Description: uplink-a
|
||||||
|
The port is optical
|
||||||
|
Negotiation force
|
||||||
|
BW 1 Gbit/s
|
||||||
|
MTU 1600 bytes
|
||||||
|
Rate period : 120 s
|
||||||
|
Input : 100 bit/s 1 packet/s
|
||||||
|
Output : 200 bit/s 2 packet/s
|
||||||
|
Peak rate:
|
||||||
|
Input : 0 bit/s peak time N/A
|
||||||
|
Output : 300 bit/s peak time N/A
|
||||||
|
Intf utilization: input 1% output 2%
|
||||||
|
gei-0/0/0/2 is administratively down, ifindex: 101
|
||||||
|
Description: spare
|
||||||
|
BW 1 Gbit/s
|
||||||
|
MTU 1500 bytes
|
||||||
|
Rate period : 120 s
|
||||||
|
Input : 0 bit/s 0 packet/s
|
||||||
|
Output : 0 bit/s 0 packet/s
|
||||||
|
Intf utilization: input 0% output 0%
|
||||||
|
"""
|
||||||
|
rows = normalize_interface_detail(
|
||||||
|
raw_text=sample,
|
||||||
|
vendor="zte",
|
||||||
|
device_type="zte_zxros",
|
||||||
|
command="show interface",
|
||||||
|
)
|
||||||
|
self.assertEqual(len(rows), 2)
|
||||||
|
self.assertEqual(rows[0]["admin"], "up")
|
||||||
|
self.assertEqual(rows[0]["input_bps"], "100")
|
||||||
|
self.assertEqual(rows[0]["out_util"], "2")
|
||||||
|
self.assertEqual(rows[1]["admin"], "admin-down")
|
||||||
|
|
||||||
|
cmd = (
|
||||||
|
"show interface | include ifindex|BW|The port is|MTU|Negotiation|"
|
||||||
|
"Description|Current|Rate|Peak|Input|Output|utilization"
|
||||||
|
)
|
||||||
|
hit = match_command(vendor_key="zte", command=cmd)
|
||||||
|
self.assertIsNotNone(hit)
|
||||||
|
assert hit is not None
|
||||||
|
self.assertEqual(hit.profile.profile_id, "zte.interface_detail")
|
||||||
|
|
||||||
|
hit6 = match_command(
|
||||||
|
vendor_key="zte",
|
||||||
|
command="show bgp vpnv6 unicast neighbor in FC00:1::1 | one-line",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(hit6)
|
||||||
|
assert hit6 is not None
|
||||||
|
self.assertEqual(hit6.profile.profile_id, "zte.bgp_vpnv6_neighbor_in")
|
||||||
|
self.assertEqual(hit6.params.get("neighbor"), "FC00:1::1")
|
||||||
|
|
||||||
|
routes = normalize_bgp_route(
|
||||||
|
raw_text=_BGP_ROUTE_IN,
|
||||||
|
command="show bgp vpnv6 unicast neighbor in FC00:1::1",
|
||||||
|
params={"neighbor": "FC00:1::1", "direction": "in", "afi": "vpnv6"},
|
||||||
|
)
|
||||||
|
self.assertGreaterEqual(len(routes), 1)
|
||||||
|
self.assertEqual(routes[0]["afi"], "vpnv6")
|
||||||
|
self.assertEqual(routes[0]["direction"], "in")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -253,6 +253,11 @@ const en = {
|
||||||
filterColumn: "Column",
|
filterColumn: "Column",
|
||||||
filterAllCols: "All columns",
|
filterAllCols: "All columns",
|
||||||
listFilterPh: "Filter NE / IP / vendor / status",
|
listFilterPh: "Filter NE / IP / vendor / status",
|
||||||
|
purposeFilter: "Task purpose",
|
||||||
|
purposeAll: "All purposes",
|
||||||
|
purposePortrait: "Portrait",
|
||||||
|
purposeCutoverHf: "Cutover HF",
|
||||||
|
colPurpose: "Purpose",
|
||||||
colNe: "NE",
|
colNe: "NE",
|
||||||
colStatus: "Status",
|
colStatus: "Status",
|
||||||
colLast: "Last collect",
|
colLast: "Last collect",
|
||||||
|
|
@ -366,7 +371,10 @@ const en = {
|
||||||
"One pair per line: before_if,after_if (comma / tab / pipe). Example:\ngei-0/1/1,gei-0/1/2\nxgei-0/1/0,xgei-0/2/0\n# comments start with #. Not slash-grouped A,B/C,D.",
|
"One pair per line: before_if,after_if (comma / tab / pipe). Example:\ngei-0/1/1,gei-0/1/2\nxgei-0/1/0,xgei-0/2/0\n# comments start with #. Not slash-grouped A,B/C,D.",
|
||||||
mappingSaved: "Mapping saved",
|
mappingSaved: "Mapping saved",
|
||||||
mappingOptionalHint:
|
mappingOptionalHint:
|
||||||
"Optional. Empty = ignore local port renames (match on non-interface keys). Fill to apply port map.",
|
"Optional. Empty = ignore local port renames (match on non-interface keys). Fill to apply port map. Main-port pairs also cover .subinterfaces (suffix kept).",
|
||||||
|
ifaceNormalize: "Interface normalize",
|
||||||
|
ifaceNormalizeHint:
|
||||||
|
"Template rules, one per line: alias,canonical. Applied before port mapping. Example:\nGE,gei\nSG,smartgroup\nXGE,xgei",
|
||||||
createJob: "Create compare job",
|
createJob: "Create compare job",
|
||||||
defaultJobName: "Cutover compare",
|
defaultJobName: "Cutover compare",
|
||||||
jobName: "Name",
|
jobName: "Name",
|
||||||
|
|
@ -475,45 +483,78 @@ const en = {
|
||||||
"Metric focus: port status (interface_brief / admin·phy·prot). Baseline → pick ports → evaluate board.",
|
"Metric focus: port status (interface_brief / admin·phy·prot). Baseline → pick ports → evaluate board.",
|
||||||
hintShort: "Monitor template sets rules; bind collect + dual baselines → expect → evaluate board.",
|
hintShort: "Monitor template sets rules; bind collect + dual baselines → expect → evaluate board.",
|
||||||
create: "Create task",
|
create: "Create task",
|
||||||
createHint: "Name the task and bind old/new business-monitor collect tasks. Mapping and baselines can be set later.",
|
createHint:
|
||||||
createHintShort: "Name + old/new collect tasks; template, mapping, baselines can come later.",
|
"Except portrait baselines, the full cutover flow runs on this page; create auto-spawns HF biz-monitor tasks on schedule.",
|
||||||
|
createHintShort:
|
||||||
|
"Pick old/new NEs → monitor template & metrics → interval/window → baselines → port map → create. HF tasks are auto-created.",
|
||||||
|
pickOldNe: "Old NE",
|
||||||
|
pickNewNe: "New NE",
|
||||||
|
pickNe: "Pick NE",
|
||||||
|
pickNeHint: "Click to pick a device",
|
||||||
|
hideNePicker: "Hide list",
|
||||||
|
nePairReady: "Both NEs selected — continue, or click a card to re-pick.",
|
||||||
|
needNePair: "Enter a name and select old/new NEs",
|
||||||
|
needProjectName: "Enter a task name",
|
||||||
|
needMonitorTemplate: "Select a monitor template",
|
||||||
|
needMetrics: "Select at least one metric",
|
||||||
|
noPortraitBaseline:
|
||||||
|
"No portrait task/batches for this NE yet — run portrait collect in business monitor first (baselines can be set later).",
|
||||||
|
baselineOptionalHint: "Baselines can be set later in detail; pick from portrait batches when available.",
|
||||||
|
metricInterval: "Per-metric interval (sec)",
|
||||||
|
metricIntervalOptional: "Per-metric interval (optional)",
|
||||||
|
metricIntervalHint: "Leave blank to use the global interval; a filled value creates a separate HF task.",
|
||||||
|
scheduleHint:
|
||||||
|
"Enter the window in your local time; stored as UTC. Scheduler pauses HF outside the window.",
|
||||||
|
sectionSchedule: "Collect interval & window",
|
||||||
|
sectionBaseline: "Baselines & mapping",
|
||||||
|
sectionDevices: "Old / new NEs",
|
||||||
|
sectionMetrics: "Collect metrics",
|
||||||
|
createSteps: "Create steps",
|
||||||
|
stepDevices: "Devices",
|
||||||
|
stepTemplate: "Template",
|
||||||
|
stepSchedule: "Schedule",
|
||||||
|
stepBaseline: "Baselines",
|
||||||
|
next: "Next",
|
||||||
|
back: "Back",
|
||||||
|
colHf: "HF",
|
||||||
|
hfReady: "Ready",
|
||||||
|
hfMissing: "Missing",
|
||||||
showCreateMore: "More: template / mapping / baselines",
|
showCreateMore: "More: template / mapping / baselines",
|
||||||
hideCreateMore: "Hide more",
|
hideCreateMore: "Hide more",
|
||||||
setupHintShort: "Collect tasks are fixed at create; set monitor template, mapping and dual baselines here.",
|
setupHintShort: "Portrait tasks feed baselines; HF tasks feed cutover collect. Configure template, metrics, window and dual baselines here.",
|
||||||
createProject: "Create task",
|
createProject: "Create task",
|
||||||
projectName: "Task name",
|
projectName: "Task name",
|
||||||
projectNamePh: "e.g. CN1/CN2 night-1 cutover",
|
projectNamePh: "e.g. PE-A / PE-B cutover-1",
|
||||||
sectionName: "1. Task name",
|
sectionName: "1. Task name",
|
||||||
sectionTasks: "2. Bind collect tasks (required)",
|
sectionTasks: "2. Bind collect tasks (required)",
|
||||||
sectionTasksHint: "Pick the old-device and new-device business-monitor tasks.",
|
sectionTasksHint: "Pick the old-device and new-device business-monitor tasks.",
|
||||||
sectionOptional: "3. Monitor template, mapping & baselines (optional)",
|
sectionOptional: "3. Monitor template, mapping & baselines (optional)",
|
||||||
sectionOptionalHint:
|
sectionOptionalHint:
|
||||||
"Skip for now if needed; set later under Baseline. Defaults to the port cutover monitor template when omitted.",
|
"Skip for now if needed; set later under Baseline. Defaults to the system monitor template when omitted.",
|
||||||
sectionPair: "Bound collect tasks",
|
sectionPair: "Bound collect tasks",
|
||||||
sectionPairHint: "Collect tasks are fixed at create time; adjust monitor template, mapping and baselines here.",
|
sectionPairHint: "Collect tasks are fixed at create time; adjust monitor template, mapping and baselines here.",
|
||||||
sectionBaseline: "Baseline batches",
|
|
||||||
sectionBaselineHint: "Both old and new baselines are required; old lists expect objects, new feeds dual-side verdict.",
|
sectionBaselineHint: "Both old and new baselines are required; old lists expect objects, new feeds dual-side verdict.",
|
||||||
optionalNone: "None",
|
optionalNone: "None",
|
||||||
monitorTemplate: "Monitor template",
|
monitorTemplate: "Monitor template",
|
||||||
monitorTemplateHint: "Chooses which metrics to compare and dual-side success semantics. Defaults to port cutover.",
|
monitorTemplateHint: "Chooses which metrics to compare and dual-side success semantics. Defaults to the system monitor template.",
|
||||||
compareTemplate: "Compare template",
|
compareTemplate: "Compare template",
|
||||||
compareTemplateBound: "Bound compare template: {{name}}",
|
compareTemplateBound: "Bound compare template: {{name}}",
|
||||||
portMappingHint:
|
portMappingHint:
|
||||||
"With a mapping, iface metrics compare only mapped ports (they must appear after cutover). Same-name ports outside the map are not a migration; old down + new up is an anomaly. No mapping means same-name compare. BGP/PW still use the batch expect set.",
|
"With a mapping, iface metrics compare only mapped ports (they must appear after cutover). Same-name ports outside the map are not a migration; old down + new up is an anomaly. No mapping means same-name compare. BGP/PW still use the batch expect set.",
|
||||||
baselineNeedTask: "Select the matching collect task first",
|
baselineNeedTask: "Select the matching collect task first",
|
||||||
baselinePickHint: "Choose from that task’s collect batches",
|
baselinePickHint: "Choose from that task’s collect batches",
|
||||||
oldTask: "Old-device collect task",
|
oldTask: "Old-device portrait task",
|
||||||
newTask: "New-device collect task",
|
newTask: "New-device portrait task",
|
||||||
portMapping: "Port mapping",
|
portMapping: "Port mapping",
|
||||||
oldBaseline: "Old baseline batch",
|
oldBaseline: "Old baseline batch",
|
||||||
newBaseline: "New baseline batch",
|
newBaseline: "New baseline batch",
|
||||||
needProjectFields: "Enter a name and pick old/new collect tasks",
|
needProjectFields: "Enter a name and pick old/new portrait tasks",
|
||||||
needExpectPorts: "Select at least one expected object for this batch",
|
needExpectPorts: "Select at least one expected object for this batch",
|
||||||
needBaselineFirst: "Save an old baseline batch first to list expect objects",
|
needBaselineFirst: "Save an old baseline batch first to list expect objects",
|
||||||
needBothBaselines: "Save both old and new baseline batches before evaluating",
|
needBothBaselines: "Save both old and new baseline batches before evaluating",
|
||||||
errOldBaselineRequired: "Old baseline batch is required",
|
errOldBaselineRequired: "Old baseline batch is required",
|
||||||
errNewBaselineRequired: "New baseline batch is required",
|
errNewBaselineRequired: "New baseline batch is required",
|
||||||
needBizStateTasks: "No business-monitor tasks yet. Create one first:",
|
needBizStateTasks: "No portrait business-monitor tasks yet. Create one first:",
|
||||||
projectCreated: "Task created",
|
projectCreated: "Task created",
|
||||||
projects: "Tasks",
|
projects: "Tasks",
|
||||||
empty: "No cutover tasks yet.",
|
empty: "No cutover tasks yet.",
|
||||||
|
|
@ -538,42 +579,66 @@ const en = {
|
||||||
tabSetup: "Baseline",
|
tabSetup: "Baseline",
|
||||||
tabBatches: "Batches",
|
tabBatches: "Batches",
|
||||||
tabBoard: "Board",
|
tabBoard: "Board",
|
||||||
saveBaseline: "Save baseline / mapping / template",
|
saveBaseline: "Save",
|
||||||
baselineSaved: "Baseline saved",
|
baselineSaved: "Baseline saved",
|
||||||
pickExpectPorts: "Expected objects (from old baseline)",
|
pickExpectPorts: "Expect",
|
||||||
portFilterPh: "Filter object / description",
|
portFilterPh: "Filter",
|
||||||
emptyBaselinePorts:
|
emptyBaselinePorts: "No baseline data",
|
||||||
"No matching metric rows in baseline (confirm monitor template and collect profiles)",
|
|
||||||
batchLabel: "Batch label",
|
batchLabel: "Batch label",
|
||||||
defaultBatchLabel: "Night N",
|
defaultBatchLabel: "Night N",
|
||||||
expectPorts: "Expected ports (old side)",
|
expectPorts: "Expected ports (old side)",
|
||||||
expectPortsPh: "Comma or newline separated",
|
expectPortsPh: "Comma or newline separated",
|
||||||
createBatch: "New batch",
|
createBatch: "New",
|
||||||
batchCreated: "Batch created",
|
batchCreated: "Batch created",
|
||||||
batches: "Batches",
|
batches: "Batches",
|
||||||
startBatch: "Start batch",
|
startBatch: "Start",
|
||||||
finishBatch: "Finish batch",
|
finishBatch: "Finish",
|
||||||
evaluate: "Evaluate now",
|
evaluate: "Evaluate",
|
||||||
evaluated: "Evaluation done",
|
evaluated: "Done",
|
||||||
statusUpdated: "Status updated",
|
statusUpdated: "Status updated",
|
||||||
board: "Progress board",
|
board: "Board",
|
||||||
metricPort: "Port status",
|
metricPort: "Port status",
|
||||||
progress: "Progress",
|
progress: "Progress",
|
||||||
anomaly: "Anomaly",
|
anomaly: "Anomaly",
|
||||||
onlyExpect: "Expected objects only",
|
onlyExpect: "Expect only",
|
||||||
onlyExpectHint: "Shows all drift by default; check to filter to this batch's expect set",
|
onlyExpectHint: "Shows all drift by default; check to filter to this batch's expect set",
|
||||||
pinCurrentBatches: "Current collect batches",
|
pinCurrentBatches: "Current collect batches",
|
||||||
pinOldBatch: "Old current batch",
|
pinOldBatch: "Old current",
|
||||||
pinNewBatch: "New current batch",
|
pinNewBatch: "New current",
|
||||||
pinLatest: "Auto latest success",
|
pinLatest: "Latest",
|
||||||
runHistory: "Run history",
|
runHistory: "Runs",
|
||||||
runLatest: "Latest run",
|
runLatest: "Latest",
|
||||||
colRuleHit: "Rule hit",
|
colRuleHit: "Rule hit",
|
||||||
mappingBound: "Mapping saved and bound to this project",
|
mappingBound: "Mapping saved and bound to this project",
|
||||||
allMetrics: "All metrics",
|
allMetrics: "All metrics",
|
||||||
colMetric: "Metric",
|
colMetric: "Metric",
|
||||||
colKey: "Object",
|
colKey: "Object",
|
||||||
colPort: "Old port",
|
colPort: "Old port",
|
||||||
|
openRedChip: "Red {{n}}",
|
||||||
|
metaPortrait: "Portrait",
|
||||||
|
metaHf: "HF",
|
||||||
|
metaBaseline: "Baseline",
|
||||||
|
baselineOk: "set",
|
||||||
|
baselineMissing: "missing",
|
||||||
|
needBaselineFirst: "Set baseline first",
|
||||||
|
enableHighfreq: "Enable HF",
|
||||||
|
highfreqReady: "HF ready",
|
||||||
|
collectNow: "Collect now",
|
||||||
|
collectTriggered: "Collect triggered",
|
||||||
|
openBizState: "Biz monitor",
|
||||||
|
hfNotBound: "Unbound",
|
||||||
|
projectCollectMetrics: "Metrics",
|
||||||
|
hfInterval: "Interval(s)",
|
||||||
|
hfStart: "Start",
|
||||||
|
hfEnd: "End",
|
||||||
|
markProjectDone: "Done / stop HF",
|
||||||
|
confirmProjectDone: "Complete project and pause all HF?",
|
||||||
|
projectMarkedDone: "Done; HF paused",
|
||||||
|
deleteProject: "Delete",
|
||||||
|
confirmDeleteProject: "Delete project? HF will pause and batches removed.",
|
||||||
|
projectDeleted: "Deleted",
|
||||||
|
acceptPassedShort: "Pass",
|
||||||
|
acceptFailedShort: "Fail",
|
||||||
colMapped: "Mapped new",
|
colMapped: "Mapped new",
|
||||||
colDesc: "Description",
|
colDesc: "Description",
|
||||||
colOldPort: "Old port",
|
colOldPort: "Old port",
|
||||||
|
|
@ -592,27 +657,34 @@ const en = {
|
||||||
verdictNotInvolved: "Not involved",
|
verdictNotInvolved: "Not involved",
|
||||||
verdictUnexpected: "Unexpected new",
|
verdictUnexpected: "Unexpected new",
|
||||||
verdictOk: "OK",
|
verdictOk: "OK",
|
||||||
enableHighfreq: "Enable high-freq collect",
|
highfreqHint: "",
|
||||||
highfreqHint:
|
boundTasks: "Bound tasks",
|
||||||
"Creates/reuses high-freq biz-state tasks on old/new NEs using the monitor template’s collect_metric_ids (empty = all compare-template metrics).",
|
oldPortraitTask: "Old portrait",
|
||||||
highfreqReady: "High-freq collect tasks ready and bound to this task",
|
newPortraitTask: "New portrait",
|
||||||
collectNow: "Collect old+new now",
|
oldHfTask: "Old HF",
|
||||||
collectTriggered: "Collect triggered",
|
newHfTask: "New HF",
|
||||||
openBizState: "Open business monitor",
|
projectCollectHint: "",
|
||||||
boundTasks: "Bound collect tasks",
|
hfWindowInactive: "HF window inactive or project done",
|
||||||
acceptTitle: "Batch acceptance summary",
|
baselinePortraitOnly: "",
|
||||||
acceptPassed: "Batch acceptance passed",
|
collectSkipped: "skipped",
|
||||||
acceptFailed: "Batch acceptance failed ({{n}} red tickets; you may continue)",
|
collectSkippedBanner: "Skipped: {{metrics}}",
|
||||||
acceptPassedShort: "Passed",
|
currentMissing: "no current",
|
||||||
acceptFailedShort: "Failed",
|
currentMissingBanner: "No current: {{metrics}}",
|
||||||
newBaselineMissingBanner: "These sheets have no usable new baseline rows; verdicts are unreliable: {{metrics}}",
|
highfreqReadyInactive: "HF bound (window inactive, no collect)",
|
||||||
acceptCarryHint: "Failure does not block the next batch; red tickets stay until resolved.",
|
collectPartial: "Partial fail (ok {{ok}} / fail {{fail}})",
|
||||||
openRedHint: "{{n}} open red ticket(s) on this task (carry allowed)",
|
errMetricNeedsBindings: "Metric \"{{metric}}\" needs bindings ({{params}}); not supported for cutover HF — remove it",
|
||||||
|
errNoProfileForMetric: "Metric \"{{metric}}\" has no collect profile for this vendor",
|
||||||
|
acceptTitle: "Acceptance",
|
||||||
|
acceptPassed: "Passed",
|
||||||
|
acceptFailed: "Failed ({{n}} red)",
|
||||||
|
newBaselineMissingBanner: "Missing new baseline: {{metrics}}",
|
||||||
|
acceptCarryHint: "May continue with red",
|
||||||
|
openRedHint: "Red {{n}}",
|
||||||
redTitle: "Red tickets",
|
redTitle: "Red tickets",
|
||||||
redOpen: "open",
|
redOpen: "open",
|
||||||
resolveRed: "Resolve",
|
resolveRed: "Resolve",
|
||||||
redResolved: "Red ticket resolved",
|
redResolved: "Resolved",
|
||||||
batchCreatedWithRed: "Batch created ({{n}} open red tickets; carry allowed)",
|
batchCreatedWithRed: "Batch created ({{n}} red)",
|
||||||
colStatus: "Status",
|
colStatus: "Status",
|
||||||
verdictUnfinished: "Unfinished",
|
verdictUnfinished: "Unfinished",
|
||||||
},
|
},
|
||||||
|
|
@ -632,7 +704,7 @@ const en = {
|
||||||
listFilterPh: "Filter name / compare template / note",
|
listFilterPh: "Filter name / compare template / note",
|
||||||
kpiTotal: "Templates",
|
kpiTotal: "Templates",
|
||||||
colName: "Name",
|
colName: "Name",
|
||||||
namePh: "e.g. Port+BGP cutover monitor",
|
namePh: "e.g. Status cutover monitor",
|
||||||
colCompare: "Compare template",
|
colCompare: "Compare template",
|
||||||
colCollect: "HF collect metrics",
|
colCollect: "HF collect metrics",
|
||||||
colNote: "Note",
|
colNote: "Note",
|
||||||
|
|
@ -640,6 +712,15 @@ const en = {
|
||||||
needName: "Name is required",
|
needName: "Name is required",
|
||||||
defaultsInvalid: "Invalid defaults JSON",
|
defaultsInvalid: "Invalid defaults JSON",
|
||||||
overridesInvalid: "Invalid sheet_overrides JSON",
|
overridesInvalid: "Invalid sheet_overrides JSON",
|
||||||
|
exportTemplate: "Export",
|
||||||
|
importTemplate: "Import",
|
||||||
|
templateExported: "Template exported",
|
||||||
|
templateImported: "Template imported",
|
||||||
|
templateImportInvalid:
|
||||||
|
"Invalid import file: need name and compare_template_id (or compare_template_name)",
|
||||||
|
templateImportHint: "Import/export JSON to share monitor templates (no job/batch data)",
|
||||||
|
templateImportCompareMissing:
|
||||||
|
"Matching compare template not found — import or create it first",
|
||||||
formHint: "Pick a compare template → same sheet tabs appear → set success conditions per sheet.",
|
formHint: "Pick a compare template → same sheet tabs appear → set success conditions per sheet.",
|
||||||
compareHint: "Owns which sheets and Key/compare fields; here you only set dual verdict rules.",
|
compareHint: "Owns which sheets and Key/compare fields; here you only set dual verdict rules.",
|
||||||
pickCompare: "Pick compare template…",
|
pickCompare: "Pick compare template…",
|
||||||
|
|
@ -816,7 +897,7 @@ const en = {
|
||||||
prev: "Back",
|
prev: "Back",
|
||||||
next: "Next",
|
next: "Next",
|
||||||
fieldTitle: "Task name",
|
fieldTitle: "Task name",
|
||||||
titlePh: "e.g. Access uplinks",
|
titlePh: "e.g. Uplink group",
|
||||||
titleRequired: "Task name is required",
|
titleRequired: "Task name is required",
|
||||||
portsRequired: "Select at least one interface",
|
portsRequired: "Select at least one interface",
|
||||||
interval: "Interval (sec)",
|
interval: "Interval (sec)",
|
||||||
|
|
@ -2450,7 +2531,7 @@ const en = {
|
||||||
sourceTopology: "Topology placeholder",
|
sourceTopology: "Topology placeholder",
|
||||||
sourceWebcrt: "WebCRT",
|
sourceWebcrt: "WebCRT",
|
||||||
regexTitle: "Regex find",
|
regexTitle: "Regex find",
|
||||||
patternPh: "Regex, e.g. ^CORE- or -BJ-",
|
patternPh: "Regex, e.g. ^CORE- or -EDGE-",
|
||||||
fieldName: "Name",
|
fieldName: "Name",
|
||||||
fieldNameIp: "Name+IP",
|
fieldNameIp: "Name+IP",
|
||||||
findMatches: "Find matches",
|
findMatches: "Find matches",
|
||||||
|
|
|
||||||
|
|
@ -253,6 +253,11 @@ const zh = {
|
||||||
filterColumn: "筛选列",
|
filterColumn: "筛选列",
|
||||||
filterAllCols: "全部列",
|
filterAllCols: "全部列",
|
||||||
listFilterPh: "筛选网元 / IP / 厂商 / 状态",
|
listFilterPh: "筛选网元 / IP / 厂商 / 状态",
|
||||||
|
purposeFilter: "任务用途",
|
||||||
|
purposeAll: "全部用途",
|
||||||
|
purposePortrait: "画像",
|
||||||
|
purposeCutoverHf: "割接高频",
|
||||||
|
colPurpose: "用途",
|
||||||
colNe: "网元",
|
colNe: "网元",
|
||||||
colStatus: "状态",
|
colStatus: "状态",
|
||||||
colLast: "最近采集",
|
colLast: "最近采集",
|
||||||
|
|
@ -365,7 +370,10 @@ const zh = {
|
||||||
"每行一对:操作前接口,操作后接口(逗号/Tab/竖线分隔)。例:\ngei-0/1/1,gei-0/1/2\nxgei-0/1/0,xgei-0/2/0\n# 井号开头为注释。不是 A,B/C,D 这种斜杠分组。",
|
"每行一对:操作前接口,操作后接口(逗号/Tab/竖线分隔)。例:\ngei-0/1/1,gei-0/1/2\nxgei-0/1/0,xgei-0/2/0\n# 井号开头为注释。不是 A,B/C,D 这种斜杠分组。",
|
||||||
mappingSaved: "映射已保存",
|
mappingSaved: "映射已保存",
|
||||||
mappingOptionalHint:
|
mappingOptionalHint:
|
||||||
"可选。留空=忽略本端端口改名,按非接口 Key 对齐。填写后才做端口映射校验。",
|
"可选。留空=忽略本端端口改名,按非接口 Key 对齐。填写后才做端口映射校验。主接口映射会自动覆盖 .子接口(后缀不变)。",
|
||||||
|
ifaceNormalize: "接口归一化",
|
||||||
|
ifaceNormalizeHint:
|
||||||
|
"模板级规则,每行:简写,规范名。先归一化再端口映射。例:\nGE,gei\nSG,smartgroup\nXGE,xgei",
|
||||||
createJob: "建立比对任务",
|
createJob: "建立比对任务",
|
||||||
defaultJobName: "割接比对",
|
defaultJobName: "割接比对",
|
||||||
jobName: "名称",
|
jobName: "名称",
|
||||||
|
|
@ -469,46 +477,77 @@ const zh = {
|
||||||
title: "割接监控",
|
title: "割接监控",
|
||||||
hint: "与业务对比独立:相对基线漂移 + 本批预期 + 老/新双端判定。复用采集任务与端口映射。",
|
hint: "与业务对比独立:相对基线漂移 + 本批预期 + 老/新双端判定。复用采集任务与端口映射。",
|
||||||
hintPort: "当前监控项:端口状态(interface_brief / admin·phy·prot)。选基线 → 勾选本批端口 → 判定看板。",
|
hintPort: "当前监控项:端口状态(interface_brief / admin·phy·prot)。选基线 → 勾选本批端口 → 判定看板。",
|
||||||
hintShort: "监控模板定判定规则;绑老/新采集与双基线 → 勾预期 → 看板评估。",
|
hintShort: "选老/新设备与监控模板,创建后自动高频采集;基线来自业务监控画像。",
|
||||||
create: "创建任务",
|
create: "创建任务",
|
||||||
createHint: "先填任务名并绑定老/新业务监控采集任务;映射与基线可稍后在详情里补。",
|
createHint:
|
||||||
createHintShort: "任务名 + 老/新采集任务即可创建;模板、映射、基线可稍后补。",
|
"除画像基线外,整条割接流程在本页完成;创建后自动生成业务监控高频任务并按计划执行。",
|
||||||
|
createHintShort:
|
||||||
|
"选老/新设备 → 监控模板与采集项 → 周期/窗口 → 基线(画像)→ 端口映射 → 创建。后台自动建高频任务。",
|
||||||
|
pickOldNe: "老设备",
|
||||||
|
pickNewNe: "新设备",
|
||||||
|
pickNe: "选择设备",
|
||||||
|
pickNeHint: "点击选择网元",
|
||||||
|
hideNePicker: "收起列表",
|
||||||
|
nePairReady: "老/新设备已选好,可进入下一步;点击上方卡片可重新选择。",
|
||||||
|
needNePair: "请填写任务名并选择老/新设备",
|
||||||
|
needProjectName: "请填写任务名称",
|
||||||
|
needMonitorTemplate: "请选择监控模板",
|
||||||
|
needMetrics: "请至少勾选一项监控项",
|
||||||
|
noPortraitBaseline: "该设备尚无画像采集任务或批次,请先在业务监控跑通画像后再选基线(可稍后补)。",
|
||||||
|
baselineOptionalHint: "基线可稍后在详情里补;有画像批次时可在此直接选定。",
|
||||||
|
metricInterval: "分项周期(秒)",
|
||||||
|
metricIntervalOptional: "分项周期(可选)",
|
||||||
|
metricIntervalHint: "留空则使用上方全局周期;填写后该监控项会单独建高频任务。",
|
||||||
|
scheduleHint: "按本机时间填写窗口,入库为 UTC;窗口外调度器自动暂停高频任务。",
|
||||||
|
sectionSchedule: "采集周期与窗口",
|
||||||
|
sectionBaseline: "基线与映射",
|
||||||
|
sectionDevices: "老 / 新设备",
|
||||||
|
sectionMetrics: "监控项",
|
||||||
|
createSteps: "创建步骤",
|
||||||
|
stepDevices: "设备",
|
||||||
|
stepTemplate: "模板",
|
||||||
|
stepSchedule: "周期",
|
||||||
|
stepBaseline: "基线",
|
||||||
|
next: "下一步",
|
||||||
|
back: "上一步",
|
||||||
|
colHf: "高频",
|
||||||
|
hfReady: "已建",
|
||||||
|
hfMissing: "未建",
|
||||||
showCreateMore: "更多:模板 / 映射 / 基线",
|
showCreateMore: "更多:模板 / 映射 / 基线",
|
||||||
hideCreateMore: "收起更多",
|
hideCreateMore: "收起更多",
|
||||||
setupHintShort: "采集任务创建后固定;此处配置监控模板、映射与双基线。",
|
setupHintShort: "画像任务用于基线;高频任务用于窗口内采集。此处配置模板、采集项、窗口与双基线。",
|
||||||
createProject: "创建任务",
|
createProject: "创建任务",
|
||||||
projectName: "任务名称",
|
projectName: "任务名称",
|
||||||
projectNamePh: "例如:CN1/CN2 第1夜割接",
|
projectNamePh: "例如:PE-A / PE-B 割接-1",
|
||||||
sectionName: "1. 任务名称",
|
sectionName: "1. 任务名称",
|
||||||
sectionTasks: "2. 绑定采集任务(必填)",
|
sectionTasks: "2. 绑定采集任务(必填)",
|
||||||
sectionTasksHint: "分别选择老设备、新设备上的业务监控任务;两侧不能为空。",
|
sectionTasksHint: "分别选择老设备、新设备上的业务监控任务;两侧不能为空。",
|
||||||
sectionOptional: "3. 监控模板、映射与基线(可选)",
|
sectionOptional: "3. 监控模板、映射与基线(可选)",
|
||||||
sectionOptionalHint: "可先跳过,创建后再到「基线配置」里设置。未选监控模板时默认绑端口割接监控。",
|
sectionOptionalHint: "可先跳过,创建后再到「基线配置」里设置。未选监控模板时使用系统默认。",
|
||||||
sectionPair: "已绑定采集任务",
|
sectionPair: "已绑定采集任务",
|
||||||
sectionPairHint: "采集任务在创建时绑定;此处可调整监控模板、映射与基线。",
|
sectionPairHint: "采集任务在创建时绑定;此处可调整监控模板、映射与基线。",
|
||||||
sectionBaseline: "基线批次",
|
|
||||||
sectionBaselineHint: "老侧与新侧基线均必填;老侧用于列出本批预期对象,新侧参与双端判定。",
|
sectionBaselineHint: "老侧与新侧基线均必填;老侧用于列出本批预期对象,新侧参与双端判定。",
|
||||||
optionalNone: "不选",
|
optionalNone: "不选",
|
||||||
monitorTemplate: "监控模板",
|
monitorTemplate: "监控模板",
|
||||||
monitorTemplateHint: "决定对比哪些 metric 与双端成功语义;默认端口割接监控。",
|
monitorTemplateHint: "决定对比哪些 metric 与双端成功语义;默认绑定系统默认监控模板。",
|
||||||
compareTemplate: "对比模板",
|
compareTemplate: "对比模板",
|
||||||
compareTemplateBound: "绑定对比模板:{{name}}",
|
compareTemplateBound: "绑定对比模板:{{name}}",
|
||||||
portMappingHint:
|
portMappingHint:
|
||||||
"有映射时,带接口字段的监控项只比映射内的口(这些口必须在割接后出现)。映射外的同名口不参与迁移对比;老口 down、新口同名 up 记为异常。未配映射则按同名直比。BGP/PW 仍用本批预期勾选。",
|
"有映射时,带接口字段的监控项只比映射内的口(这些口必须在割接后出现)。映射外的同名口不参与迁移对比;老口 down、新口同名 up 记为异常。未配映射则按同名直比。BGP/PW 仍用本批预期勾选。",
|
||||||
baselineNeedTask: "请先选择对应采集任务",
|
baselineNeedTask: "请先选择对应采集任务",
|
||||||
baselinePickHint: "从该任务的历史采集批次中选择",
|
baselinePickHint: "从该任务的历史采集批次中选择",
|
||||||
oldTask: "老设备采集任务",
|
oldTask: "老设备画像任务",
|
||||||
newTask: "新设备采集任务",
|
newTask: "新设备画像任务",
|
||||||
portMapping: "端口映射",
|
portMapping: "端口映射",
|
||||||
oldBaseline: "老设备基线批次",
|
oldBaseline: "老设备基线批次",
|
||||||
newBaseline: "新设备基线批次",
|
newBaseline: "新设备基线批次",
|
||||||
needProjectFields: "请填写任务名并选择老/新采集任务",
|
needProjectFields: "请填写任务名并选择老/新画像任务",
|
||||||
needExpectPorts: "请先勾选本批预期迁移的对象",
|
needExpectPorts: "请先勾选本批预期迁移的对象",
|
||||||
needBaselineFirst: "请先保存老设备基线批次,才能列出预期对象",
|
needBaselineFirst: "先设基线",
|
||||||
needBothBaselines: "请先保存老、新两侧基线批次后再评估",
|
needBothBaselines: "请先保存老、新两侧基线批次后再评估",
|
||||||
errOldBaselineRequired: "缺少老设备基线批次",
|
errOldBaselineRequired: "缺少老设备基线批次",
|
||||||
errNewBaselineRequired: "缺少新设备基线批次",
|
errNewBaselineRequired: "缺少新设备基线批次",
|
||||||
needBizStateTasks: "尚无业务监控任务,请先创建:",
|
needBizStateTasks: "尚无业务监控(画像)任务,请先创建:",
|
||||||
projectCreated: "任务已创建",
|
projectCreated: "任务已创建",
|
||||||
projects: "任务",
|
projects: "任务",
|
||||||
empty: "暂无割接任务。",
|
empty: "暂无割接任务。",
|
||||||
|
|
@ -533,20 +572,20 @@ const zh = {
|
||||||
tabSetup: "基线配置",
|
tabSetup: "基线配置",
|
||||||
tabBatches: "割接批次",
|
tabBatches: "割接批次",
|
||||||
tabBoard: "判定看板",
|
tabBoard: "判定看板",
|
||||||
saveBaseline: "保存基线/映射/模板",
|
saveBaseline: "保存配置",
|
||||||
baselineSaved: "基线已保存",
|
baselineSaved: "基线已保存",
|
||||||
pickExpectPorts: "本批预期对象(来自老基线)",
|
pickExpectPorts: "本批预期",
|
||||||
portFilterPh: "筛选对象 / 描述",
|
portFilterPh: "筛选",
|
||||||
emptyBaselinePorts: "基线中无对应 metric 数据(确认监控模板与采集任务已勾选相应 profile)",
|
emptyBaselinePorts: "基线无数据",
|
||||||
batchLabel: "批次标签",
|
batchLabel: "批次标签",
|
||||||
defaultBatchLabel: "第N晚",
|
defaultBatchLabel: "第N晚",
|
||||||
expectPorts: "本批预期端口(老侧)",
|
expectPorts: "本批预期端口(老侧)",
|
||||||
expectPortsPh: "逗号或换行分隔",
|
expectPortsPh: "逗号或换行分隔",
|
||||||
createBatch: "新建批次",
|
createBatch: "新建",
|
||||||
batchCreated: "批次已创建",
|
batchCreated: "批次已创建",
|
||||||
batches: "批次",
|
batches: "批次",
|
||||||
startBatch: "本批开始",
|
startBatch: "启动",
|
||||||
finishBatch: "本批完成",
|
finishBatch: "收工",
|
||||||
evaluate: "立即判定",
|
evaluate: "立即判定",
|
||||||
evaluated: "判定完成",
|
evaluated: "判定完成",
|
||||||
statusUpdated: "状态已更新",
|
statusUpdated: "状态已更新",
|
||||||
|
|
@ -554,12 +593,12 @@ const zh = {
|
||||||
metricPort: "端口状态",
|
metricPort: "端口状态",
|
||||||
progress: "进度",
|
progress: "进度",
|
||||||
anomaly: "异常",
|
anomaly: "异常",
|
||||||
onlyExpect: "仅看预期对象",
|
onlyExpect: "仅预期",
|
||||||
onlyExpectHint: "默认显示全部漂移;勾选后只看本批预期",
|
onlyExpectHint: "默认显示全部漂移;勾选后只看本批预期",
|
||||||
pinCurrentBatches: "当前采集批次",
|
pinCurrentBatches: "当前采集批次",
|
||||||
pinOldBatch: "老侧当前批",
|
pinOldBatch: "老侧当前",
|
||||||
pinNewBatch: "新侧当前批",
|
pinNewBatch: "新侧当前",
|
||||||
pinLatest: "自动最新成功批",
|
pinLatest: "最新",
|
||||||
runHistory: "判定历史",
|
runHistory: "判定历史",
|
||||||
runLatest: "最新一次",
|
runLatest: "最新一次",
|
||||||
colRuleHit: "命中规则",
|
colRuleHit: "命中规则",
|
||||||
|
|
@ -568,6 +607,31 @@ const zh = {
|
||||||
colMetric: "监控项",
|
colMetric: "监控项",
|
||||||
colKey: "对象",
|
colKey: "对象",
|
||||||
colPort: "老侧端口",
|
colPort: "老侧端口",
|
||||||
|
openRedChip: "红单 {{n}}",
|
||||||
|
metaPortrait: "画像",
|
||||||
|
metaHf: "HF",
|
||||||
|
metaBaseline: "基线",
|
||||||
|
baselineOk: "已设",
|
||||||
|
baselineMissing: "缺",
|
||||||
|
needBaselineFirst: "先设基线",
|
||||||
|
enableHighfreq: "开启高频",
|
||||||
|
highfreqReady: "高频已就绪",
|
||||||
|
collectNow: "立即采集",
|
||||||
|
collectTriggered: "已触发采集",
|
||||||
|
openBizState: "业务监控",
|
||||||
|
hfNotBound: "未绑定",
|
||||||
|
projectCollectMetrics: "采集项",
|
||||||
|
hfInterval: "周期(秒)",
|
||||||
|
hfStart: "开始",
|
||||||
|
hfEnd: "结束",
|
||||||
|
markProjectDone: "完成并停HF",
|
||||||
|
confirmProjectDone: "确认完成项目并暂停全部高频?",
|
||||||
|
projectMarkedDone: "已完成,高频已停",
|
||||||
|
deleteProject: "删除",
|
||||||
|
confirmDeleteProject: "确认删除项目?将停高频并清批次记录。",
|
||||||
|
projectDeleted: "已删除",
|
||||||
|
acceptPassedShort: "通过",
|
||||||
|
acceptFailedShort: "未过",
|
||||||
colMapped: "映射新口",
|
colMapped: "映射新口",
|
||||||
colDesc: "描述",
|
colDesc: "描述",
|
||||||
colOldPort: "老端口",
|
colOldPort: "老端口",
|
||||||
|
|
@ -586,27 +650,38 @@ const zh = {
|
||||||
verdictNotInvolved: "未涉及",
|
verdictNotInvolved: "未涉及",
|
||||||
verdictUnexpected: "意外新增",
|
verdictUnexpected: "意外新增",
|
||||||
verdictOk: "正常",
|
verdictOk: "正常",
|
||||||
enableHighfreq: "开启高频采集",
|
highfreqHint: "",
|
||||||
highfreqHint:
|
boundTasks: "绑定任务",
|
||||||
"按当前监控模板的 collect_metric_ids(空则取对比模板全量)为老/新设备各建或复用高频采集任务并勾选对应 profile。",
|
oldPortraitTask: "老画像",
|
||||||
highfreqReady: "高频采集任务已就绪(已绑定到本任务)",
|
newPortraitTask: "新画像",
|
||||||
collectNow: "立即采集老+新",
|
oldHfTask: "老HF",
|
||||||
collectTriggered: "已触发采集",
|
newHfTask: "新HF",
|
||||||
openBizState: "打开业务监控",
|
projectCollectHint: "",
|
||||||
boundTasks: "当前绑定采集任务",
|
hfWindowInactive: "高频窗口未到/已结束,或项目已完成,当前不可采集",
|
||||||
acceptTitle: "本批验收小结",
|
baselinePortraitOnly: "",
|
||||||
acceptPassed: "本批验收通过",
|
collectSkipped: "未采",
|
||||||
acceptFailed: "本批验收未通过(红单 {{n}},可带红开下一批)",
|
collectSkippedBanner: "未采:{{metrics}}",
|
||||||
acceptPassedShort: "通过",
|
currentMissing: "缺当前",
|
||||||
acceptFailedShort: "未通过",
|
currentMissingBanner: "缺当前批次:{{metrics}}",
|
||||||
newBaselineMissingBanner: "这些表没有可用的新侧基线内容,判定不可信:{{metrics}}",
|
highfreqReadyInactive: "高频已绑定(窗口未开,未立即采集)",
|
||||||
acceptCarryHint: "验收不过不拦截:可新建下一批继续;红单保留留痕,可手动关闭。",
|
collectPartial: "部分失败(成功 {{ok}} / 失败 {{fail}})",
|
||||||
openRedHint: "任务仍有 {{n}} 条未关闭红单(带红继续)",
|
confirmProjectDone: "确认完成项目并暂停全部高频?",
|
||||||
redTitle: "红单留痕",
|
projectMarkedDone: "已完成,高频已停",
|
||||||
|
confirmDeleteProject: "确认删除项目?将停高频并清批次记录。",
|
||||||
|
projectDeleted: "已删除",
|
||||||
|
errMetricNeedsBindings: "监控项「{{metric}}」需要参数绑定({{params}}),割接高频暂不支持,请去掉该项",
|
||||||
|
errNoProfileForMetric: "监控项「{{metric}}」在当前设备厂商下无可用采集 profile",
|
||||||
|
acceptTitle: "本批验收",
|
||||||
|
acceptPassed: "验收通过",
|
||||||
|
acceptFailed: "验收未过(红单 {{n}})",
|
||||||
|
newBaselineMissingBanner: "缺新基线:{{metrics}}",
|
||||||
|
acceptCarryHint: "可带红开下一批",
|
||||||
|
openRedHint: "红单 {{n}}",
|
||||||
|
redTitle: "红单",
|
||||||
redOpen: "未关闭",
|
redOpen: "未关闭",
|
||||||
resolveRed: "关闭",
|
resolveRed: "关闭",
|
||||||
redResolved: "红单已关闭",
|
redResolved: "已关闭",
|
||||||
batchCreatedWithRed: "批次已创建(当前有 {{n}} 条未关闭红单,允许带红继续)",
|
batchCreatedWithRed: "已建批(红单 {{n}})",
|
||||||
colStatus: "状态",
|
colStatus: "状态",
|
||||||
verdictUnfinished: "未完成",
|
verdictUnfinished: "未完成",
|
||||||
},
|
},
|
||||||
|
|
@ -626,7 +701,7 @@ const zh = {
|
||||||
listFilterPh: "筛选名称 / 对比模板 / 备注",
|
listFilterPh: "筛选名称 / 对比模板 / 备注",
|
||||||
kpiTotal: "模板数",
|
kpiTotal: "模板数",
|
||||||
colName: "名称",
|
colName: "名称",
|
||||||
namePh: "例如:端口+BGP 割接监控",
|
namePh: "例如:状态割接监控",
|
||||||
colCompare: "对比模板",
|
colCompare: "对比模板",
|
||||||
colCollect: "高频采集项",
|
colCollect: "高频采集项",
|
||||||
colNote: "备注",
|
colNote: "备注",
|
||||||
|
|
@ -634,6 +709,13 @@ const zh = {
|
||||||
needName: "请填写名称",
|
needName: "请填写名称",
|
||||||
defaultsInvalid: "defaults JSON 无效",
|
defaultsInvalid: "defaults JSON 无效",
|
||||||
overridesInvalid: "sheet_overrides JSON 无效",
|
overridesInvalid: "sheet_overrides JSON 无效",
|
||||||
|
exportTemplate: "导出",
|
||||||
|
importTemplate: "导入",
|
||||||
|
templateExported: "模板已导出",
|
||||||
|
templateImported: "模板已导入",
|
||||||
|
templateImportInvalid: "导入文件无效:需要 name 与 compare_template_id(或 compare_template_name)",
|
||||||
|
templateImportHint: "导入/导出 JSON,便于私下传递监控模板(不含任务/批次数据)",
|
||||||
|
templateImportCompareMissing: "未找到对应对比模板,请先导入或创建同名对比模板",
|
||||||
formHint: "先选对比模板 → 出现与对比模板相同的表页签 → 为每张表配置成功条件。",
|
formHint: "先选对比模板 → 出现与对比模板相同的表页签 → 为每张表配置成功条件。",
|
||||||
compareHint: "决定有哪些表以及 Key/比对字段;此处只配置双端判定。",
|
compareHint: "决定有哪些表以及 Key/比对字段;此处只配置双端判定。",
|
||||||
pickCompare: "选择对比模板…",
|
pickCompare: "选择对比模板…",
|
||||||
|
|
@ -809,7 +891,7 @@ const zh = {
|
||||||
prev: "上一步",
|
prev: "上一步",
|
||||||
next: "下一步",
|
next: "下一步",
|
||||||
fieldTitle: "任务名称",
|
fieldTitle: "任务名称",
|
||||||
titlePh: "例如:接入侧上联",
|
titlePh: "例如:上联链路组",
|
||||||
titleRequired: "请填写任务名称",
|
titleRequired: "请填写任务名称",
|
||||||
portsRequired: "请至少选择一个接口",
|
portsRequired: "请至少选择一个接口",
|
||||||
interval: "周期(秒)",
|
interval: "周期(秒)",
|
||||||
|
|
@ -2423,7 +2505,7 @@ const zh = {
|
||||||
sourceTopology: "拓扑占位",
|
sourceTopology: "拓扑占位",
|
||||||
sourceWebcrt: "WebCRT",
|
sourceWebcrt: "WebCRT",
|
||||||
regexTitle: "正则查找",
|
regexTitle: "正则查找",
|
||||||
patternPh: "正则,如 ^CORE- 或 -BJ-",
|
patternPh: "正则,如 ^CORE- 或 -EDGE-",
|
||||||
fieldName: "名称",
|
fieldName: "名称",
|
||||||
fieldNameIp: "名称+IP",
|
fieldNameIp: "名称+IP",
|
||||||
findMatches: "查找匹配",
|
findMatches: "查找匹配",
|
||||||
|
|
|
||||||
|
|
@ -10062,8 +10062,10 @@ html.login-page--paused .login-page__flare {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
max-height: 70vh;
|
max-height: min(70vh, 720px);
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
|
min-height: 0;
|
||||||
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
.mt-editor__nav-head {
|
.mt-editor__nav-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -10074,11 +10076,15 @@ html.login-page--paused .login-page__flare {
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
padding: 2px 4px 6px;
|
padding: 2px 4px 6px;
|
||||||
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
border-bottom: 1px solid rgba(148, 163, 184, 0.2);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.mt-editor__nav-list {
|
.mt-editor__nav-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: auto;
|
||||||
}
|
}
|
||||||
.mt-editor__nav-item {
|
.mt-editor__nav-item {
|
||||||
appearance: none;
|
appearance: none;
|
||||||
|
|
@ -10116,6 +10122,10 @@ html.login-page--paused .login-page__flare {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 12px;
|
gap: 12px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
max-height: min(70vh, 720px);
|
||||||
|
overflow: auto;
|
||||||
|
align-self: stretch;
|
||||||
}
|
}
|
||||||
.mt-editor__pane-head {
|
.mt-editor__pane-head {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -10251,7 +10261,7 @@ html.login-page--paused .login-page__flare {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr auto 1fr;
|
grid-template-columns: 1fr auto 1fr;
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
align-items: start;
|
align-items: stretch;
|
||||||
}
|
}
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.mt-rule-card__grid {
|
.mt-rule-card__grid {
|
||||||
|
|
@ -10262,26 +10272,51 @@ html.login-page--paused .login-page__flare {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.mt-rule-arrow {
|
.mt-rule-arrow {
|
||||||
align-self: center;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
padding-top: 22px;
|
padding: 0 2px;
|
||||||
|
align-self: stretch;
|
||||||
|
}
|
||||||
|
.mt-side-col {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
}
|
}
|
||||||
.mt-side-col__label {
|
.mt-side-col__label {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: 650;
|
font-weight: 650;
|
||||||
margin-bottom: 6px;
|
margin-bottom: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
.mt-side {
|
.mt-side {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
.mt-side-empty-box {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
border: 1px dashed rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 14px 12px;
|
||||||
|
min-height: 88px;
|
||||||
|
background: rgba(2, 6, 23, 0.2);
|
||||||
}
|
}
|
||||||
.mt-side-empty {
|
.mt-side-empty {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
line-height: 1.45;
|
||||||
}
|
}
|
||||||
.mt-or-wrap {
|
.mt-or-wrap {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -10733,18 +10768,75 @@ html.login-page--paused .login-page__flare {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
}
|
}
|
||||||
.bm-detail__red {
|
|
||||||
margin-left: 4px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
|
||||||
.bm-detail__actions {
|
.bm-detail__actions {
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
gap: 8px;
|
gap: 6px;
|
||||||
}
|
}
|
||||||
.bm-detail__link {
|
.bm-detail__link {
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
align-self: center;
|
align-self: center;
|
||||||
}
|
}
|
||||||
|
.bm-setup__meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 8px;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 12px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
padding: 6px 0 2px;
|
||||||
|
}
|
||||||
|
.bm-setup__sep {
|
||||||
|
opacity: 0.4;
|
||||||
|
}
|
||||||
|
.bm-setup__binds {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
.bm-setup__cmp {
|
||||||
|
display: flex;
|
||||||
|
align-items: end;
|
||||||
|
font-size: 12px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
}
|
||||||
|
.bm-batches__toolbar {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 12px;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.bm-batches__toolbar-main,
|
||||||
|
.bm-batches__toolbar-side {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 6px;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
.bm-board__status {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 10px 16px;
|
||||||
|
align-items: baseline;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(15, 23, 42, 0.35);
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
|
.bm-board__verdict {
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
.bm-board__verdict.is-ok {
|
||||||
|
color: #34d399;
|
||||||
|
}
|
||||||
|
.bm-board__verdict.is-bad,
|
||||||
|
.bm-board__num.is-bad {
|
||||||
|
color: #f87171;
|
||||||
|
}
|
||||||
|
.bm-board__num {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
.bm-batches {
|
.bm-batches {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|
@ -10752,11 +10844,6 @@ html.login-page--paused .login-page__flare {
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
.bm-batches__toolbar {
|
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: end;
|
|
||||||
}
|
|
||||||
.bm-batches__main {
|
.bm-batches__main {
|
||||||
min-height: 320px;
|
min-height: 320px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|
@ -10788,6 +10875,220 @@ html.login-page--paused .login-page__flare {
|
||||||
overflow: auto;
|
overflow: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Cutover create wizard */
|
||||||
|
.bm-create {
|
||||||
|
min-height: min(52vh, 520px);
|
||||||
|
}
|
||||||
|
.bm-create__pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.bm-steps {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 6px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
}
|
||||||
|
.bm-steps__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(15, 23, 42, 0.22);
|
||||||
|
color: inherit;
|
||||||
|
padding: 8px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
font: inherit;
|
||||||
|
opacity: 0.72;
|
||||||
|
transition: border-color 0.15s ease, opacity 0.15s ease, background 0.15s ease;
|
||||||
|
}
|
||||||
|
.bm-steps__item:hover {
|
||||||
|
opacity: 0.92;
|
||||||
|
}
|
||||||
|
.bm-steps__item.is-active {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: rgba(148, 163, 184, 0.55);
|
||||||
|
background: rgba(51, 65, 85, 0.35);
|
||||||
|
}
|
||||||
|
.bm-steps__item.is-done {
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
.bm-steps__num {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
width: 22px;
|
||||||
|
height: 22px;
|
||||||
|
border-radius: 999px;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
background: rgba(148, 163, 184, 0.22);
|
||||||
|
}
|
||||||
|
.bm-steps__item.is-active .bm-steps__num,
|
||||||
|
.bm-steps__item.is-done .bm-steps__num {
|
||||||
|
background: rgba(148, 163, 184, 0.4);
|
||||||
|
}
|
||||||
|
.bm-steps__label {
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.2;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.bm-steps {
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.bm-ne-slot {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: rgba(15, 23, 42, 0.28);
|
||||||
|
color: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
font: inherit;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease;
|
||||||
|
min-height: 78px;
|
||||||
|
}
|
||||||
|
.bm-ne-slot:hover {
|
||||||
|
border-color: rgba(148, 163, 184, 0.5);
|
||||||
|
}
|
||||||
|
.bm-ne-slot.is-picking {
|
||||||
|
border-color: rgba(148, 163, 184, 0.65);
|
||||||
|
box-shadow: 0 0 0 1px rgba(148, 163, 184, 0.2);
|
||||||
|
}
|
||||||
|
.bm-ne-slot.is-filled {
|
||||||
|
background: rgba(15, 23, 42, 0.4);
|
||||||
|
}
|
||||||
|
.bm-ne-slot__name {
|
||||||
|
font-weight: 650;
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.bm-ne-slot__meta,
|
||||||
|
.bm-ne-slot__empty {
|
||||||
|
font-size: 12px;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.bm-ne-slot__empty {
|
||||||
|
opacity: 0.55;
|
||||||
|
}
|
||||||
|
.bm-ne-picker {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
background: rgba(2, 6, 23, 0.28);
|
||||||
|
}
|
||||||
|
.bm-ne-picker__head {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.bm-ne-picker__table {
|
||||||
|
max-height: min(36vh, 320px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.bm-ne-picker .pt-list-pager,
|
||||||
|
.bm-ne-picker .pager {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-top: 4px;
|
||||||
|
}
|
||||||
|
.bm-ne-picker__table tr.is-selected td {
|
||||||
|
background: rgba(51, 65, 85, 0.45);
|
||||||
|
}
|
||||||
|
.bm-metric-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
.bm-metric-chip {
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.35);
|
||||||
|
border-radius: 6px;
|
||||||
|
background: rgba(15, 23, 42, 0.35);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
.bm-metric-chip.is-on {
|
||||||
|
opacity: 1;
|
||||||
|
border-color: rgba(148, 163, 184, 0.65);
|
||||||
|
background: rgba(51, 65, 85, 0.45);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.bm-sched-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.bm-sched-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.bm-sched-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
.bm-interval-table {
|
||||||
|
max-height: min(28vh, 240px);
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
.bm-hf-bindings {
|
||||||
|
margin: 6px 0 0;
|
||||||
|
padding-left: 1.1em;
|
||||||
|
list-style: disc;
|
||||||
|
}
|
||||||
|
.bm-create__summary {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 10px 14px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid rgba(148, 163, 184, 0.28);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(15, 23, 42, 0.28);
|
||||||
|
}
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.bm-create__summary {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.bm-create-modal .modal-footer,
|
||||||
|
.bm-create-modal [data-slot="footer"],
|
||||||
|
.bm-create-modal .app-heroui-modal__dialog [data-slot="footer"] {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.bm-create__footer-spacer {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 8px;
|
||||||
|
}
|
||||||
|
.bm-list-hf {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.bm-list-hf__iv {
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
.bs-bind-panel {
|
.bs-bind-panel {
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
border: 1px solid rgba(148, 163, 184, 0.25);
|
border: 1px solid rgba(148, 163, 184, 0.25);
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,7 @@ type Template = {
|
||||||
key_fields: string[];
|
key_fields: string[];
|
||||||
iface_fields: string[];
|
iface_fields: string[];
|
||||||
compare_fields: string[];
|
compare_fields: string[];
|
||||||
|
iface_normalize_rules?: { from: string; to: string }[];
|
||||||
note: string;
|
note: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -405,6 +406,7 @@ function templateExportPayload(tpl: Template) {
|
||||||
exported_at: new Date().toISOString(),
|
exported_at: new Date().toISOString(),
|
||||||
name: tpl.name,
|
name: tpl.name,
|
||||||
note: tpl.note || "",
|
note: tpl.note || "",
|
||||||
|
iface_normalize_rules: [...(tpl.iface_normalize_rules || [])],
|
||||||
metrics,
|
metrics,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -413,6 +415,7 @@ function parseTemplateImport(raw: unknown): {
|
||||||
name: string;
|
name: string;
|
||||||
note: string;
|
note: string;
|
||||||
metrics: MetricSheet[];
|
metrics: MetricSheet[];
|
||||||
|
iface_normalize_rules?: { from: string; to: string }[];
|
||||||
} | null {
|
} | null {
|
||||||
if (!raw || typeof raw !== "object") return null;
|
if (!raw || typeof raw !== "object") return null;
|
||||||
const o = raw as Record<string, unknown>;
|
const o = raw as Record<string, unknown>;
|
||||||
|
|
@ -462,7 +465,49 @@ function parseTemplateImport(raw: unknown): {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (!metrics.length) return null;
|
if (!metrics.length) return null;
|
||||||
return { name, note, metrics };
|
const normRaw = o.iface_normalize_rules;
|
||||||
|
const iface_normalize_rules: { from: string; to: string }[] = [];
|
||||||
|
if (Array.isArray(normRaw)) {
|
||||||
|
for (const item of normRaw) {
|
||||||
|
if (!item || typeof item !== "object") continue;
|
||||||
|
const r = item as Record<string, unknown>;
|
||||||
|
const fr = String(r.from || "").trim();
|
||||||
|
const to = String(r.to || "").trim();
|
||||||
|
if (fr && to) iface_normalize_rules.push({ from: fr, to });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { name, note, metrics, iface_normalize_rules };
|
||||||
|
}
|
||||||
|
|
||||||
|
function ifaceNormRulesToText(rules?: { from: string; to: string }[]): string {
|
||||||
|
return (rules || []).map((r) => `${r.from},${r.to}`).join("\n");
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseIfaceNormText(text: string): { from: string; to: string }[] {
|
||||||
|
const out: { from: string; to: string }[] = [];
|
||||||
|
for (const line of text.split(/\r?\n/)) {
|
||||||
|
const s = line.trim();
|
||||||
|
if (!s || s.startsWith("#")) continue;
|
||||||
|
let fr = "";
|
||||||
|
let to = "";
|
||||||
|
if (s.includes(",")) {
|
||||||
|
const i = s.indexOf(",");
|
||||||
|
fr = s.slice(0, i).trim();
|
||||||
|
to = s.slice(i + 1).trim();
|
||||||
|
} else if (s.includes("\t")) {
|
||||||
|
const i = s.indexOf("\t");
|
||||||
|
fr = s.slice(0, i).trim();
|
||||||
|
to = s.slice(i + 1).trim();
|
||||||
|
} else {
|
||||||
|
const parts = s.split(/\s+/);
|
||||||
|
if (parts.length >= 2) {
|
||||||
|
fr = parts[0];
|
||||||
|
to = parts.slice(1).join(" ");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (fr && to) out.push({ from: fr, to });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function downloadJsonFile(filename: string, data: unknown) {
|
function downloadJsonFile(filename: string, data: unknown) {
|
||||||
|
|
@ -721,6 +766,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
const [tplEditId, setTplEditId] = useState("");
|
const [tplEditId, setTplEditId] = useState("");
|
||||||
const [tplName, setTplName] = useState("");
|
const [tplName, setTplName] = useState("");
|
||||||
const [tplNote, setTplNote] = useState("");
|
const [tplNote, setTplNote] = useState("");
|
||||||
|
const [tplNormText, setTplNormText] = useState("");
|
||||||
const [tplSheets, setTplSheets] = useState<MetricSheet[]>([]);
|
const [tplSheets, setTplSheets] = useState<MetricSheet[]>([]);
|
||||||
const [tplSheetIdx, setTplSheetIdx] = useState(0);
|
const [tplSheetIdx, setTplSheetIdx] = useState(0);
|
||||||
const [tplPaneTab, setTplPaneTab] = useState<"fields" | "filters">("fields");
|
const [tplPaneTab, setTplPaneTab] = useState<"fields" | "filters">("fields");
|
||||||
|
|
@ -1127,6 +1173,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
setTplEditId("");
|
setTplEditId("");
|
||||||
setTplName("");
|
setTplName("");
|
||||||
setTplNote("");
|
setTplNote("");
|
||||||
|
setTplNormText("XXVGE,xxvgei\nXGE,xgei\nCGE,cgei\nGE,gei\nSG,smartgroup");
|
||||||
setTplSheets([]);
|
setTplSheets([]);
|
||||||
setTplSheetIdx(0);
|
setTplSheetIdx(0);
|
||||||
setTplPaneTab("fields");
|
setTplPaneTab("fields");
|
||||||
|
|
@ -1139,6 +1186,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
setTplEditId(tpl.id);
|
setTplEditId(tpl.id);
|
||||||
setTplName(tpl.name);
|
setTplName(tpl.name);
|
||||||
setTplNote(tpl.note || "");
|
setTplNote(tpl.note || "");
|
||||||
|
setTplNormText(ifaceNormRulesToText(tpl.iface_normalize_rules));
|
||||||
setTplSheets(sheets.length ? sheets.map(cloneSheet) : []);
|
setTplSheets(sheets.length ? sheets.map(cloneSheet) : []);
|
||||||
setTplSheetIdx(0);
|
setTplSheetIdx(0);
|
||||||
setTplPaneTab("fields");
|
setTplPaneTab("fields");
|
||||||
|
|
@ -1229,6 +1277,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
const body = {
|
const body = {
|
||||||
name: tplName || tplSheets.map((s) => sheetLabel(s)).join("+"),
|
name: tplName || tplSheets.map((s) => sheetLabel(s)).join("+"),
|
||||||
note: tplNote,
|
note: tplNote,
|
||||||
|
iface_normalize_rules: parseIfaceNormText(tplNormText),
|
||||||
metrics: tplSheets.map((s) => ({
|
metrics: tplSheets.map((s) => ({
|
||||||
sheet_id: sheetIdentity(s),
|
sheet_id: sheetIdentity(s),
|
||||||
title: s.title || sheetIdentity(s),
|
title: s.title || sheetIdentity(s),
|
||||||
|
|
@ -1293,6 +1342,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
await bizCompareCreateTemplate({
|
await bizCompareCreateTemplate({
|
||||||
name: body.name,
|
name: body.name,
|
||||||
note: body.note,
|
note: body.note,
|
||||||
|
iface_normalize_rules: body.iface_normalize_rules || [],
|
||||||
metrics: body.metrics.map((s) => ({
|
metrics: body.metrics.map((s) => ({
|
||||||
sheet_id: sheetIdentity(s),
|
sheet_id: sheetIdentity(s),
|
||||||
title: s.title || sheetIdentity(s),
|
title: s.title || sheetIdentity(s),
|
||||||
|
|
@ -1890,6 +1940,19 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="ct-editor__norm">
|
||||||
|
<div className="ct-editor__pick-title">{t("bizCompare.ifaceNormalize")}</div>
|
||||||
|
<p className="muted ct-editor__pick-hint">{t("bizCompare.ifaceNormalizeHint")}</p>
|
||||||
|
<textarea
|
||||||
|
className="ct-editor__norm-area"
|
||||||
|
rows={5}
|
||||||
|
value={tplNormText}
|
||||||
|
onChange={(e) => setTplNormText(e.target.value)}
|
||||||
|
placeholder={"GE,gei\nSG,smartgroup"}
|
||||||
|
aria-label={t("bizCompare.ifaceNormalize")}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="ct-editor__pick">
|
<div className="ct-editor__pick">
|
||||||
<div className="ct-editor__pick-head">
|
<div className="ct-editor__pick-head">
|
||||||
<span className="ct-editor__pick-title">
|
<span className="ct-editor__pick-title">
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
||||||
import { Button, Input, Modal } from "@heroui/react";
|
import { Button, Input, Modal } from "@heroui/react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { AppModalShell } from "../../components/ui/AppModalShell";
|
import { AppModalShell } from "../../components/ui/AppModalShell";
|
||||||
import { FieldSelect } from "../../components/ui/FieldSelect";
|
import { FieldSelect } from "../../components/ui/FieldSelect";
|
||||||
|
|
@ -70,6 +70,71 @@ type MonitorTpl = {
|
||||||
note?: string;
|
note?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MONITOR_EXPORT_FORMAT = "netx.biz_monitor_template";
|
||||||
|
const MONITOR_EXPORT_VERSION = 1;
|
||||||
|
|
||||||
|
function downloadJsonFile(filename: string, data: unknown) {
|
||||||
|
const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json" });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
|
|
||||||
|
function monitorExportPayload(tpl: MonitorTpl) {
|
||||||
|
return {
|
||||||
|
format: MONITOR_EXPORT_FORMAT,
|
||||||
|
version: MONITOR_EXPORT_VERSION,
|
||||||
|
exported_at: new Date().toISOString(),
|
||||||
|
name: tpl.name,
|
||||||
|
note: tpl.note || "",
|
||||||
|
compare_template_id: tpl.compare_template_id || "",
|
||||||
|
compare_template_name: tpl.compare_template_name || "",
|
||||||
|
collect_metric_ids: [...(tpl.collect_metric_ids || [])],
|
||||||
|
defaults: { ...(tpl.defaults || {}) },
|
||||||
|
sheet_overrides: JSON.parse(JSON.stringify(tpl.sheet_overrides || [])),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseMonitorImport(raw: unknown): {
|
||||||
|
name: string;
|
||||||
|
note: string;
|
||||||
|
compare_template_id: string;
|
||||||
|
compare_template_name: string;
|
||||||
|
collect_metric_ids: string[];
|
||||||
|
defaults: Record<string, unknown>;
|
||||||
|
sheet_overrides: SheetOverride[];
|
||||||
|
} | null {
|
||||||
|
if (!raw || typeof raw !== "object") return null;
|
||||||
|
const o = raw as Record<string, unknown>;
|
||||||
|
const name = String(o.name || "").trim();
|
||||||
|
if (!name) return null;
|
||||||
|
const compare_template_id = String(o.compare_template_id || "").trim();
|
||||||
|
const compare_template_name = String(o.compare_template_name || "").trim();
|
||||||
|
if (!compare_template_id && !compare_template_name) return null;
|
||||||
|
const collect = Array.isArray(o.collect_metric_ids)
|
||||||
|
? (o.collect_metric_ids as unknown[]).map((x) => String(x).trim()).filter(Boolean)
|
||||||
|
: [];
|
||||||
|
const defaults =
|
||||||
|
o.defaults && typeof o.defaults === "object" && !Array.isArray(o.defaults)
|
||||||
|
? (o.defaults as Record<string, unknown>)
|
||||||
|
: { dual_mode: "migrate_pair", out_of_expect: "strict" };
|
||||||
|
const sheet_overrides = Array.isArray(o.sheet_overrides)
|
||||||
|
? (o.sheet_overrides as SheetOverride[])
|
||||||
|
: [];
|
||||||
|
return {
|
||||||
|
name,
|
||||||
|
note: String(o.note || ""),
|
||||||
|
compare_template_id,
|
||||||
|
compare_template_name,
|
||||||
|
collect_metric_ids: collect,
|
||||||
|
defaults,
|
||||||
|
sheet_overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const PRESENCE_TOKENS = [
|
const PRESENCE_TOKENS = [
|
||||||
{ id: "removed", labelKey: "tokRemoved" },
|
{ id: "removed", labelKey: "tokRemoved" },
|
||||||
{ id: "added", labelKey: "tokAdded" },
|
{ id: "added", labelKey: "tokAdded" },
|
||||||
|
|
@ -481,7 +546,9 @@ function SideGroupsEditor({
|
||||||
</label>
|
</label>
|
||||||
) : null}
|
) : null}
|
||||||
{dontCare ? (
|
{dontCare ? (
|
||||||
|
<div className="mt-side-empty-box">
|
||||||
<p className="muted mt-side-empty">{t("bizMonitorTpl.sideDontCareHint")}</p>
|
<p className="muted mt-side-empty">{t("bizMonitorTpl.sideDontCareHint")}</p>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{groups.map((group, gi) => (
|
{groups.map((group, gi) => (
|
||||||
|
|
@ -759,6 +826,7 @@ export function BizMonitorTemplatesPage() {
|
||||||
const [showMore, setShowMore] = useState(false);
|
const [showMore, setShowMore] = useState(false);
|
||||||
const [showAdvancedJson, setShowAdvancedJson] = useState(false);
|
const [showAdvancedJson, setShowAdvancedJson] = useState(false);
|
||||||
const [overridesText, setOverridesText] = useState("[]");
|
const [overridesText, setOverridesText] = useState("[]");
|
||||||
|
const tplImportRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
const [mon, cmp, metrics] = await Promise.all([
|
const [mon, cmp, metrics] = await Promise.all([
|
||||||
|
|
@ -970,6 +1038,60 @@ export function BizMonitorTemplatesPage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const exportTemplate = (tpl: MonitorTpl) => {
|
||||||
|
const payload = monitorExportPayload(tpl);
|
||||||
|
const safe = (tpl.name || "template").replace(/[^\w\u4e00-\u9fff.-]+/g, "_").slice(0, 64);
|
||||||
|
downloadJsonFile(`netx-monitor-template-${safe}.json`, payload);
|
||||||
|
showOk(t("bizMonitorTpl.templateExported"));
|
||||||
|
};
|
||||||
|
|
||||||
|
const importTemplateFile = async (file: File) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const text = await file.text();
|
||||||
|
let parsed: unknown;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
showError(t("bizMonitorTpl.templateImportInvalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const body = parseMonitorImport(parsed);
|
||||||
|
if (!body) {
|
||||||
|
showError(t("bizMonitorTpl.templateImportInvalid"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let compareId = body.compare_template_id;
|
||||||
|
if (!compareTpls.some((c) => c.id === compareId)) {
|
||||||
|
compareId = "";
|
||||||
|
}
|
||||||
|
if (!compareId && body.compare_template_name) {
|
||||||
|
const want = body.compare_template_name.trim().toLowerCase();
|
||||||
|
const byName = compareTpls.find((c) => c.name.trim().toLowerCase() === want);
|
||||||
|
compareId = byName?.id || "";
|
||||||
|
}
|
||||||
|
if (!compareId) {
|
||||||
|
showError(t("bizMonitorTpl.templateImportCompareMissing"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await bizMonitorCreateTemplate({
|
||||||
|
name: body.name,
|
||||||
|
note: body.note,
|
||||||
|
compare_template_id: compareId,
|
||||||
|
collect_metric_ids: body.collect_metric_ids,
|
||||||
|
defaults: body.defaults,
|
||||||
|
sheet_overrides: body.sheet_overrides,
|
||||||
|
});
|
||||||
|
showOk(t("bizMonitorTpl.templateImported"));
|
||||||
|
await refresh();
|
||||||
|
} catch (e) {
|
||||||
|
showError(formatErr(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
if (tplImportRef.current) tplImportRef.current.value = "";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const fieldChoices: MetricField[] =
|
const fieldChoices: MetricField[] =
|
||||||
activeFields.length > 0
|
activeFields.length > 0
|
||||||
? activeFields
|
? activeFields
|
||||||
|
|
@ -982,6 +1104,24 @@ export function BizMonitorTemplatesPage() {
|
||||||
<div className="panel__toolbar">
|
<div className="panel__toolbar">
|
||||||
<h2>{t("bizMonitorTpl.title")}</h2>
|
<h2>{t("bizMonitorTpl.title")}</h2>
|
||||||
<div className="btn-row">
|
<div className="btn-row">
|
||||||
|
<input
|
||||||
|
ref={tplImportRef}
|
||||||
|
type="file"
|
||||||
|
accept="application/json,.json"
|
||||||
|
style={{ display: "none" }}
|
||||||
|
onChange={(e) => {
|
||||||
|
const f = e.target.files?.[0];
|
||||||
|
if (f) void importTemplateFile(f);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="secondary"
|
||||||
|
isDisabled={busy}
|
||||||
|
onPress={() => tplImportRef.current?.click()}
|
||||||
|
>
|
||||||
|
{t("bizMonitorTpl.importTemplate")}
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="primary" onPress={openCreate}>
|
<Button size="sm" variant="primary" onPress={openCreate}>
|
||||||
{t("bizMonitorTpl.create")}
|
{t("bizMonitorTpl.create")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
@ -990,6 +1130,8 @@ export function BizMonitorTemplatesPage() {
|
||||||
<p className="panel__hint muted">{t("bizMonitorTpl.hint")}</p>
|
<p className="panel__hint muted">{t("bizMonitorTpl.hint")}</p>
|
||||||
<p className="muted" style={{ fontSize: 12, marginTop: -4 }}>
|
<p className="muted" style={{ fontSize: 12, marginTop: -4 }}>
|
||||||
<Link to="/network/cutover/compare-templates">{t("bizMonitorTpl.openCompare")}</Link>
|
<Link to="/network/cutover/compare-templates">{t("bizMonitorTpl.openCompare")}</Link>
|
||||||
|
{" · "}
|
||||||
|
{t("bizMonitorTpl.templateImportHint")}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<div className="pt-list">
|
<div className="pt-list">
|
||||||
|
|
@ -1035,6 +1177,9 @@ export function BizMonitorTemplatesPage() {
|
||||||
<Button size="sm" variant="primary" onPress={() => openEdit(row)}>
|
<Button size="sm" variant="primary" onPress={() => openEdit(row)}>
|
||||||
{t("bizMonitorTpl.edit")}
|
{t("bizMonitorTpl.edit")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button size="sm" variant="secondary" onPress={() => exportTemplate(row)}>
|
||||||
|
{t("bizMonitorTpl.exportTemplate")}
|
||||||
|
</Button>
|
||||||
<Button size="sm" variant="ghost" isDisabled={busy} onPress={() => void remove(row.id)}>
|
<Button size="sm" variant="ghost" isDisabled={busy} onPress={() => void remove(row.id)}>
|
||||||
{t("bizMonitorTpl.delete")}
|
{t("bizMonitorTpl.delete")}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ type TaskRow = {
|
||||||
ne_ip: string;
|
ne_ip: string;
|
||||||
vendor: string;
|
vendor: string;
|
||||||
note?: string;
|
note?: string;
|
||||||
|
purpose?: string;
|
||||||
status: string;
|
status: string;
|
||||||
collect_running: boolean;
|
collect_running: boolean;
|
||||||
last_error: string;
|
last_error: string;
|
||||||
|
|
@ -257,6 +258,7 @@ export function BizStatePage() {
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [listKeyword, setListKeyword] = useState("");
|
const [listKeyword, setListKeyword] = useState("");
|
||||||
const debouncedListKw = useDebouncedValue(listKeyword, 250);
|
const debouncedListKw = useDebouncedValue(listKeyword, 250);
|
||||||
|
const [purposeFilter, setPurposeFilter] = useState<"all" | "portrait" | "cutover_hf">("all");
|
||||||
|
|
||||||
// create-task modal (all valid CLI targets)
|
// create-task modal (all valid CLI targets)
|
||||||
const [createOpen, setCreateOpen] = useState(false);
|
const [createOpen, setCreateOpen] = useState(false);
|
||||||
|
|
@ -296,9 +298,11 @@ export function BizStatePage() {
|
||||||
const debouncedSheetKw = useDebouncedValue(sheetKeyword, 200);
|
const debouncedSheetKw = useDebouncedValue(sheetKeyword, 200);
|
||||||
|
|
||||||
const refreshTasks = useCallback(async () => {
|
const refreshTasks = useCallback(async () => {
|
||||||
const res = await bizStateListTasks();
|
const purpose =
|
||||||
|
purposeFilter === "all" ? "" : purposeFilter === "portrait" ? "portrait" : "cutover_hf";
|
||||||
|
const res = await bizStateListTasks(purpose);
|
||||||
setTasks((res.items || []) as TaskRow[]);
|
setTasks((res.items || []) as TaskRow[]);
|
||||||
}, []);
|
}, [purposeFilter]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -308,7 +312,7 @@ export function BizStatePage() {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount once
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- refresh when purpose filter / refreshTasks changes
|
||||||
}, [refreshTasks]);
|
}, [refreshTasks]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -344,7 +348,7 @@ export function BizStatePage() {
|
||||||
if (!kw) return tasks;
|
if (!kw) return tasks;
|
||||||
return tasks.filter((row) => {
|
return tasks.filter((row) => {
|
||||||
const blob =
|
const blob =
|
||||||
`${row.ne_name} ${row.ne_ip} ${row.vendor} ${row.note || ""} ${row.status} ${row.source || ""} ${row.last_error}`.toLowerCase();
|
`${row.ne_name} ${row.ne_ip} ${row.vendor} ${row.note || ""} ${row.purpose || ""} ${row.status} ${row.source || ""} ${row.last_error}`.toLowerCase();
|
||||||
return blob.includes(kw);
|
return blob.includes(kw);
|
||||||
});
|
});
|
||||||
}, [tasks, debouncedListKw]);
|
}, [tasks, debouncedListKw]);
|
||||||
|
|
@ -740,6 +744,17 @@ export function BizStatePage() {
|
||||||
placeholder={t("bizState.listFilterPh")}
|
placeholder={t("bizState.listFilterPh")}
|
||||||
onChange={(e) => setListKeyword(e.target.value)}
|
onChange={(e) => setListKeyword(e.target.value)}
|
||||||
/>
|
/>
|
||||||
|
<FieldSelect
|
||||||
|
value={purposeFilter}
|
||||||
|
onChange={(e) =>
|
||||||
|
setPurposeFilter(e.target.value as "all" | "portrait" | "cutover_hf")
|
||||||
|
}
|
||||||
|
aria-label={t("bizState.purposeFilter")}
|
||||||
|
>
|
||||||
|
<option value="all">{t("bizState.purposeAll")}</option>
|
||||||
|
<option value="portrait">{t("bizState.purposePortrait")}</option>
|
||||||
|
<option value="cutover_hf">{t("bizState.purposeCutoverHf")}</option>
|
||||||
|
</FieldSelect>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="pt-list-table-wrap">
|
<div className="pt-list-table-wrap">
|
||||||
|
|
@ -748,6 +763,7 @@ export function BizStatePage() {
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t("bizState.colNe")}</th>
|
<th>{t("bizState.colNe")}</th>
|
||||||
<th>{t("bizState.colSource")}</th>
|
<th>{t("bizState.colSource")}</th>
|
||||||
|
<th>{t("bizState.colPurpose")}</th>
|
||||||
<th>{t("bizState.colInterval")}</th>
|
<th>{t("bizState.colInterval")}</th>
|
||||||
<th>{t("bizState.scheduleEnabled")}</th>
|
<th>{t("bizState.scheduleEnabled")}</th>
|
||||||
<th>{t("bizState.colStatus")}</th>
|
<th>{t("bizState.colStatus")}</th>
|
||||||
|
|
@ -771,6 +787,15 @@ export function BizStatePage() {
|
||||||
{row.source || "managed"}
|
{row.source || "managed"}
|
||||||
</NmStatusChip>
|
</NmStatusChip>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<NmStatusChip
|
||||||
|
color={row.purpose === "cutover_hf" ? "accent" : "default"}
|
||||||
|
>
|
||||||
|
{row.purpose === "cutover_hf"
|
||||||
|
? t("bizState.purposeCutoverHf")
|
||||||
|
: t("bizState.purposePortrait")}
|
||||||
|
</NmStatusChip>
|
||||||
|
</td>
|
||||||
<td className="pt-list-num">
|
<td className="pt-list-num">
|
||||||
{fmtIntervalLabel(
|
{fmtIntervalLabel(
|
||||||
Number(row.interval_sec || 3600),
|
Number(row.interval_sec || 3600),
|
||||||
|
|
@ -824,7 +849,7 @@ export function BizStatePage() {
|
||||||
for (let i = 0; i < 20; i++) {
|
for (let i = 0; i < 20; i++) {
|
||||||
await new Promise((r) => setTimeout(r, 1500));
|
await new Promise((r) => setTimeout(r, 1500));
|
||||||
await refreshTasks();
|
await refreshTasks();
|
||||||
const cur = (await bizStateListTasks()).items?.find(
|
const cur = (await bizStateListTasks(purposeFilter === "all" ? "" : purposeFilter)).items?.find(
|
||||||
(x: any) => x.id === row.id,
|
(x: any) => x.id === row.id,
|
||||||
);
|
);
|
||||||
if (!cur?.collect_running) break;
|
if (!cur?.collect_running) break;
|
||||||
|
|
@ -853,7 +878,7 @@ export function BizStatePage() {
|
||||||
))}
|
))}
|
||||||
{!filteredTasks.length ? (
|
{!filteredTasks.length ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={7}>
|
<td colSpan={8}>
|
||||||
<div className="pt-list-empty">{t("bizState.empty")}</div>
|
<div className="pt-list-empty">{t("bizState.empty")}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
|
||||||
|
|
@ -1790,8 +1790,10 @@ export const bizStateSetBindings = (
|
||||||
{ bindings },
|
{ bindings },
|
||||||
);
|
);
|
||||||
|
|
||||||
export const bizStateListTasks = () =>
|
export const bizStateListTasks = (purpose = "") => {
|
||||||
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-state/tasks");
|
const q = purpose ? `?purpose=${encodeURIComponent(purpose)}` : "";
|
||||||
|
return apiGet<{ items: Record<string, unknown>[] }>(`/v1/biz-state/tasks${q}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const bizStateCreateTask = (body: Record<string, unknown>) =>
|
export const bizStateCreateTask = (body: Record<string, unknown>) =>
|
||||||
apiPost<Record<string, unknown>>("/v1/biz-state/tasks", body);
|
apiPost<Record<string, unknown>>("/v1/biz-state/tasks", body);
|
||||||
|
|
@ -1988,6 +1990,20 @@ export const bizCompareDownloadRun = async (runId: string): Promise<void> => {
|
||||||
export const bizMigrationListProjects = () =>
|
export const bizMigrationListProjects = () =>
|
||||||
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-migration/projects");
|
apiGet<{ items: Record<string, unknown>[] }>("/v1/biz-migration/projects");
|
||||||
|
|
||||||
|
export const bizMigrationNePortrait = (params: { source: string; ne_id: string; limit?: number }) => {
|
||||||
|
const q = new URLSearchParams();
|
||||||
|
q.set("source", params.source || "managed");
|
||||||
|
q.set("ne_id", params.ne_id);
|
||||||
|
if (params.limit != null) q.set("limit", String(params.limit));
|
||||||
|
return apiGet<{
|
||||||
|
source: string;
|
||||||
|
ne_id: string;
|
||||||
|
task: Record<string, unknown> | null;
|
||||||
|
batches: Array<{ id: string; started_at?: string | null; status?: string; row_count?: number }>;
|
||||||
|
hint?: string;
|
||||||
|
}>(`/v1/biz-migration/ne-portrait?${q.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
export const bizMigrationCreateProject = (body: Record<string, unknown>) =>
|
export const bizMigrationCreateProject = (body: Record<string, unknown>) =>
|
||||||
apiPost<Record<string, unknown>>("/v1/biz-migration/projects", body);
|
apiPost<Record<string, unknown>>("/v1/biz-migration/projects", body);
|
||||||
|
|
||||||
|
|
@ -1997,6 +2013,9 @@ export const bizMigrationPatchProject = (projectId: string, body: Record<string,
|
||||||
body,
|
body,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const bizMigrationDeleteProject = (projectId: string) =>
|
||||||
|
apiDelete<{ ok?: boolean }>(`/v1/biz-migration/projects/${encodeURIComponent(projectId)}`);
|
||||||
|
|
||||||
export const bizMigrationListBatches = (projectId: string) =>
|
export const bizMigrationListBatches = (projectId: string) =>
|
||||||
apiGet<{ items: Record<string, unknown>[] }>(
|
apiGet<{ items: Record<string, unknown>[] }>(
|
||||||
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`,
|
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/batches`,
|
||||||
|
|
@ -2037,7 +2056,16 @@ export const bizMigrationListBaselineExpect = (projectId: string) =>
|
||||||
|
|
||||||
export const bizMigrationEnsureHighfreq = (
|
export const bizMigrationEnsureHighfreq = (
|
||||||
projectId: string,
|
projectId: string,
|
||||||
body: { interval_sec?: number; retention_days?: number; collect_now?: boolean } = {},
|
body: {
|
||||||
|
interval_sec?: number;
|
||||||
|
retention_days?: number;
|
||||||
|
collect_now?: boolean;
|
||||||
|
collect_metric_ids?: string[];
|
||||||
|
hf_start_at?: string | null;
|
||||||
|
hf_end_at?: string | null;
|
||||||
|
old_ne?: Record<string, unknown>;
|
||||||
|
new_ne?: Record<string, unknown>;
|
||||||
|
} = {},
|
||||||
) =>
|
) =>
|
||||||
apiPost<Record<string, unknown>>(
|
apiPost<Record<string, unknown>>(
|
||||||
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/ensure-highfreq`,
|
`/v1/biz-migration/projects/${encodeURIComponent(projectId)}/ensure-highfreq`,
|
||||||
|
|
|
||||||
|
|
@ -44,3 +44,31 @@ export function formatSystemTime(
|
||||||
timeZone: systemTimeZone,
|
timeZone: systemTimeZone,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<input type="datetime-local">` (browser system wall clock) → UTC ISO with `Z` for API/DB.
|
||||||
|
* Global deployments store UTC; each operator edits in their local timezone.
|
||||||
|
*/
|
||||||
|
export function localDatetimeInputToUtcIso(localValue: string): string | null {
|
||||||
|
const v = String(localValue || "").trim();
|
||||||
|
if (!v) return null;
|
||||||
|
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2})(?::(\d{2}))?/.exec(v);
|
||||||
|
if (!m) return null;
|
||||||
|
const y = Number(m[1]);
|
||||||
|
const mo = Number(m[2]) - 1;
|
||||||
|
const day = Number(m[3]);
|
||||||
|
const h = Number(m[4]);
|
||||||
|
const mi = Number(m[5]);
|
||||||
|
const sec = Number(m[6] || 0);
|
||||||
|
const local = new Date(y, mo, day, h, mi, sec);
|
||||||
|
if (Number.isNaN(local.getTime())) return null;
|
||||||
|
return local.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** API/DB UTC (naive or `Z`) → `datetime-local` value in the browser system timezone. */
|
||||||
|
export function utcIsoToLocalDatetimeInput(iso: string | null | undefined): string {
|
||||||
|
const d = parseApiTime(iso);
|
||||||
|
if (!d) return "";
|
||||||
|
const pad = (n: number) => String(n).padStart(2, "0");
|
||||||
|
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue