mirror of
https://github.com/hansjone/netx.git
synced 2026-10-11 06:50:49 +08:00
Fix empty BGP route false positives and add batch alias UX.
Skip Dest/Network headers when Total routes is 0; capture direction from match groups; expose workbook compare/data/sheet stats and prefer batch aliases in compare/cutover pickers. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
f321413dbf
commit
1fcd613bc6
16 changed files with 327 additions and 39 deletions
|
|
@ -347,12 +347,13 @@ def _task_brief(db: Session, task_id: str) -> dict[str, Any]:
|
||||||
def _batch_brief(db: Session, batch_id: str) -> dict[str, Any]:
|
def _batch_brief(db: Session, batch_id: str) -> dict[str, Any]:
|
||||||
b = db.get(BizStateBatch, batch_id) if batch_id else None
|
b = db.get(BizStateBatch, batch_id) if batch_id else None
|
||||||
if not b:
|
if not b:
|
||||||
return {"id": batch_id or "", "status": "", "started_at": None}
|
return {"id": batch_id or "", "status": "", "started_at": None, "alias": ""}
|
||||||
return {
|
return {
|
||||||
"id": b.id,
|
"id": b.id,
|
||||||
"status": b.status,
|
"status": b.status,
|
||||||
"started_at": b.started_at.isoformat() if b.started_at else None,
|
"started_at": b.started_at.isoformat() if b.started_at else None,
|
||||||
"row_count": b.row_count,
|
"row_count": b.row_count,
|
||||||
|
"alias": str(getattr(b, "alias", "") or ""),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,24 @@ _ROUTE_RE = re.compile(
|
||||||
)
|
)
|
||||||
_DIR_RE = re.compile(r"(?i)\bneighbor\s+(in|out)\s+")
|
_DIR_RE = re.compile(r"(?i)\bneighbor\s+(in|out)\s+")
|
||||||
_NEI_RE = re.compile(r"(?i)\bneighbor\s+(?:in|out)\s+(\S+)")
|
_NEI_RE = re.compile(r"(?i)\bneighbor\s+(?:in|out)\s+(\S+)")
|
||||||
|
_TOTAL_RE = re.compile(r"(?i)total\s+number\s+of\s+routes\s*:\s*(\d+)")
|
||||||
|
_HEADER_NETS = frozenset(
|
||||||
|
{
|
||||||
|
"network",
|
||||||
|
"dest",
|
||||||
|
"destination",
|
||||||
|
"next",
|
||||||
|
"hop",
|
||||||
|
"metric",
|
||||||
|
"locprf",
|
||||||
|
"loc_prf",
|
||||||
|
"intag",
|
||||||
|
"rtprf",
|
||||||
|
"tag",
|
||||||
|
"path",
|
||||||
|
"from",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def _detect_direction(command: str, params: dict[str, str] | None) -> str:
|
def _detect_direction(command: str, params: dict[str, str] | None) -> str:
|
||||||
|
|
@ -36,15 +54,24 @@ def _detect_neighbor(command: str, params: dict[str, str] | None) -> str:
|
||||||
return m.group(1).strip() if m else ""
|
return m.group(1).strip() if m else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _looks_like_prefix(net: str) -> bool:
|
||||||
|
tok = str(net or "").strip()
|
||||||
|
if not tok or tok.lower() in _HEADER_NETS:
|
||||||
|
return False
|
||||||
|
if re.fullmatch(r"\d{1,3}(?:\.\d{1,3}){3}/\d{1,2}", tok):
|
||||||
|
return True
|
||||||
|
# IPv6 prefix / bare address
|
||||||
|
if ":" in tok and re.search(r"[0-9A-Fa-f]:", tok):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _split_rest(rest: str) -> tuple[str, str, str, str]:
|
def _split_rest(rest: str) -> tuple[str, str, str, str]:
|
||||||
"""Parse trailing Metric LocPrf Tag/RtPrf Path columns (some may be blank)."""
|
"""Parse trailing Metric LocPrf Tag/RtPrf Path columns (some may be blank)."""
|
||||||
parts = str(rest or "").split()
|
parts = str(rest or "").split()
|
||||||
if not parts:
|
if not parts:
|
||||||
return "", "", "", ""
|
return "", "", "", ""
|
||||||
# Last token(s) are AS path + origin; path ends with i|e|?
|
|
||||||
path = " ".join(parts)
|
|
||||||
metric = loc = tag = ""
|
metric = loc = tag = ""
|
||||||
# Heuristic: numeric-only leading fields are metric/loc/tag when present
|
|
||||||
nums: list[str] = []
|
nums: list[str] = []
|
||||||
path_parts: list[str] = []
|
path_parts: list[str] = []
|
||||||
for p in parts:
|
for p in parts:
|
||||||
|
|
@ -62,6 +89,12 @@ def _split_rest(rest: str) -> tuple[str, str, str, str]:
|
||||||
return metric, loc, tag, path
|
return metric, loc, tag, path
|
||||||
|
|
||||||
|
|
||||||
|
def _empty_if_total_zero(raw_text: str) -> bool:
|
||||||
|
"""True when device reports Total number of routes: 0."""
|
||||||
|
m = _TOTAL_RE.search(str(raw_text or ""))
|
||||||
|
return bool(m and int(m.group(1)) == 0)
|
||||||
|
|
||||||
|
|
||||||
def _map_fsm_rows(
|
def _map_fsm_rows(
|
||||||
rows: list[dict[str, Any]],
|
rows: list[dict[str, Any]],
|
||||||
*,
|
*,
|
||||||
|
|
@ -74,7 +107,10 @@ def _map_fsm_rows(
|
||||||
seen: set[str] = set()
|
seen: set[str] = set()
|
||||||
for r in rows:
|
for r in rows:
|
||||||
net = row_get(r, "NETWORK", "network")
|
net = row_get(r, "NETWORK", "network")
|
||||||
if not net or net.lower() == "network" or net in seen:
|
if not _looks_like_prefix(net) or net in seen:
|
||||||
|
continue
|
||||||
|
nh = row_get(r, "NEXT_HOP", "next_hop")
|
||||||
|
if str(nh or "").strip().lower() in _HEADER_NETS:
|
||||||
continue
|
continue
|
||||||
seen.add(net)
|
seen.add(net)
|
||||||
path = row_get(r, "PATH", "path")
|
path = row_get(r, "PATH", "path")
|
||||||
|
|
@ -85,7 +121,7 @@ def _map_fsm_rows(
|
||||||
"neighbor": neighbor[:128],
|
"neighbor": neighbor[:128],
|
||||||
"direction": direction[:8],
|
"direction": direction[:8],
|
||||||
"network": net[:128],
|
"network": net[:128],
|
||||||
"next_hop": row_get(r, "NEXT_HOP", "next_hop")[:128],
|
"next_hop": nh[:128],
|
||||||
"metric": row_get(r, "METRIC", "metric")[:32],
|
"metric": row_get(r, "METRIC", "metric")[:32],
|
||||||
"loc_prf": row_get(r, "LOC_PRF", "loc_prf")[:32],
|
"loc_prf": row_get(r, "LOC_PRF", "loc_prf")[:32],
|
||||||
"tag": row_get(r, "TAG", "RT_PRF", "tag")[:32],
|
"tag": row_get(r, "TAG", "RT_PRF", "tag")[:32],
|
||||||
|
|
@ -115,19 +151,21 @@ def _hand_parse(
|
||||||
if not line.strip():
|
if not line.strip():
|
||||||
continue
|
continue
|
||||||
low = line.strip().lower()
|
low = line.strip().lower()
|
||||||
if low.startswith("network") or "next hop" in low:
|
if low.startswith(("network", "dest ", "destination")):
|
||||||
continue
|
continue
|
||||||
if low.startswith("status") or low.startswith("origin") or low.startswith("routes "):
|
if "next hop" in low or low.startswith("status") or low.startswith("origin"):
|
||||||
|
continue
|
||||||
|
if low.startswith("routes ") or low.startswith("current as"):
|
||||||
continue
|
continue
|
||||||
if low.startswith("local ") or low.startswith("remote ") or low.startswith("total "):
|
if low.startswith("local ") or low.startswith("remote ") or low.startswith("total "):
|
||||||
continue
|
continue
|
||||||
if low.startswith("route distinguisher"):
|
if low.startswith("route distinguisher") or low.startswith("valid ") or low.startswith("invalid "):
|
||||||
continue
|
continue
|
||||||
m = _ROUTE_RE.match(line)
|
m = _ROUTE_RE.match(line)
|
||||||
if not m:
|
if not m:
|
||||||
continue
|
continue
|
||||||
net = m.group("net")
|
net = m.group("net")
|
||||||
if net in seen:
|
if not _looks_like_prefix(net) or net in seen:
|
||||||
continue
|
continue
|
||||||
seen.add(net)
|
seen.add(net)
|
||||||
metric, loc, tag, path = _split_rest(m.group("rest"))
|
metric, loc, tag, path = _split_rest(m.group("rest"))
|
||||||
|
|
@ -165,6 +203,9 @@ def normalize_bgp_route(
|
||||||
vrf = _detect_vrf(command, params)
|
vrf = _detect_vrf(command, params)
|
||||||
neighbor = _detect_neighbor(command, params)
|
neighbor = _detect_neighbor(command, params)
|
||||||
direction = _detect_direction(command, params)
|
direction = _detect_direction(command, params)
|
||||||
|
if _empty_if_total_zero(raw_text):
|
||||||
|
return []
|
||||||
|
|
||||||
tables = dict(fsm_tables or {})
|
tables = dict(fsm_tables or {})
|
||||||
if not any(tables.get(k) for k in RULE_KEYS):
|
if not any(tables.get(k) for k in RULE_KEYS):
|
||||||
platform = resolve_cli_platform(
|
platform = resolve_cli_platform(
|
||||||
|
|
|
||||||
|
|
@ -965,7 +965,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
parser_id="bgp_route",
|
parser_id="bgp_route",
|
||||||
title="BGP VPNv4 Neighbor In",
|
title="BGP VPNv4 Neighbor In",
|
||||||
command_template="show bgp vpnv4 unicast neighbor in <neighbor> | one-line",
|
command_template="show bgp vpnv4 unicast neighbor in <neighbor> | one-line",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+(?P<direction>in)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp vpnv4 unicast neighbor in",
|
textfsm_command="show bgp vpnv4 unicast neighbor in",
|
||||||
description="Routes learned from VPNv4 neighbor; summary aux for peer state.",
|
description="Routes learned from VPNv4 neighbor; summary aux for peer state.",
|
||||||
placeholders=[_BGP_NEIGHBOR_VPNV4],
|
placeholders=[_BGP_NEIGHBOR_VPNV4],
|
||||||
|
|
@ -990,7 +990,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
parser_id="bgp_route",
|
parser_id="bgp_route",
|
||||||
title="BGP VPNv4 Neighbor Out",
|
title="BGP VPNv4 Neighbor Out",
|
||||||
command_template="show bgp vpnv4 unicast neighbor out <neighbor> | one-line",
|
command_template="show bgp vpnv4 unicast neighbor out <neighbor> | one-line",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+neighbor\s+(?P<direction>out)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp vpnv4 unicast neighbor out",
|
textfsm_command="show bgp vpnv4 unicast neighbor out",
|
||||||
description="Routes advertised to VPNv4 neighbor (large; bind neighbor).",
|
description="Routes advertised to VPNv4 neighbor (large; bind neighbor).",
|
||||||
placeholders=[_BGP_NEIGHBOR_VPNV4],
|
placeholders=[_BGP_NEIGHBOR_VPNV4],
|
||||||
|
|
@ -1016,7 +1016,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
parser_id="bgp_route",
|
parser_id="bgp_route",
|
||||||
title="BGP VPNv6 Neighbor In",
|
title="BGP VPNv6 Neighbor In",
|
||||||
command_template="show bgp vpnv6 unicast neighbor in <neighbor> | one-line",
|
command_template="show bgp vpnv6 unicast neighbor in <neighbor> | one-line",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+in\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+(?P<direction>in)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp vpnv6 unicast neighbor in",
|
textfsm_command="show bgp vpnv6 unicast neighbor in",
|
||||||
description="Routes learned from VPNv6 neighbor; summary aux for peer state.",
|
description="Routes learned from VPNv6 neighbor; summary aux for peer state.",
|
||||||
placeholders=[_BGP_NEIGHBOR_VPNV6],
|
placeholders=[_BGP_NEIGHBOR_VPNV6],
|
||||||
|
|
@ -1041,7 +1041,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
parser_id="bgp_route",
|
parser_id="bgp_route",
|
||||||
title="BGP VPNv6 Neighbor Out",
|
title="BGP VPNv6 Neighbor Out",
|
||||||
command_template="show bgp vpnv6 unicast neighbor out <neighbor> | one-line",
|
command_template="show bgp vpnv6 unicast neighbor out <neighbor> | one-line",
|
||||||
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+out\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
match=r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+neighbor\s+(?P<direction>out)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$",
|
||||||
textfsm_command="show bgp vpnv6 unicast neighbor out",
|
textfsm_command="show bgp vpnv6 unicast neighbor out",
|
||||||
description="Routes advertised to VPNv6 neighbor (large; bind neighbor).",
|
description="Routes advertised to VPNv6 neighbor (large; bind neighbor).",
|
||||||
placeholders=[_BGP_NEIGHBOR_VPNV6],
|
placeholders=[_BGP_NEIGHBOR_VPNV6],
|
||||||
|
|
@ -1069,7 +1069,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
command_template="show bgp vpnv4 unicast vrf <vrf> neighbor in <neighbor> | one-line",
|
command_template="show bgp vpnv4 unicast vrf <vrf> neighbor in <neighbor> | one-line",
|
||||||
match=(
|
match=(
|
||||||
r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+"
|
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*$"
|
r"neighbor\s+(?P<direction>in)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
|
||||||
),
|
),
|
||||||
textfsm_command="show bgp vpnv4 unicast neighbor in",
|
textfsm_command="show bgp vpnv4 unicast neighbor in",
|
||||||
description="Per-VRF CE peer routes; discover (vrf,neighbor) from BGP peer intent.",
|
description="Per-VRF CE peer routes; discover (vrf,neighbor) from BGP peer intent.",
|
||||||
|
|
@ -1097,7 +1097,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
command_template="show bgp vpnv4 unicast vrf <vrf> neighbor out <neighbor> | one-line",
|
command_template="show bgp vpnv4 unicast vrf <vrf> neighbor out <neighbor> | one-line",
|
||||||
match=(
|
match=(
|
||||||
r"(?i)^\s*show\s+bgp\s+vpnv4\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+"
|
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*$"
|
r"neighbor\s+(?P<direction>out)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
|
||||||
),
|
),
|
||||||
textfsm_command="show bgp vpnv4 unicast neighbor out",
|
textfsm_command="show bgp vpnv4 unicast neighbor out",
|
||||||
description="Per-VRF CE peer advertised routes; discover pairs from BGP peer intent.",
|
description="Per-VRF CE peer advertised routes; discover pairs from BGP peer intent.",
|
||||||
|
|
@ -1126,7 +1126,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
command_template="show bgp vpnv6 unicast vrf <vrf> neighbor in <neighbor> | one-line",
|
command_template="show bgp vpnv6 unicast vrf <vrf> neighbor in <neighbor> | one-line",
|
||||||
match=(
|
match=(
|
||||||
r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+"
|
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*$"
|
r"neighbor\s+(?P<direction>in)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
|
||||||
),
|
),
|
||||||
textfsm_command="show bgp vpnv6 unicast neighbor in",
|
textfsm_command="show bgp vpnv6 unicast neighbor in",
|
||||||
description="Per-VRF IPv6 CE peer routes; discover pairs from BGP peer intent.",
|
description="Per-VRF IPv6 CE peer routes; discover pairs from BGP peer intent.",
|
||||||
|
|
@ -1154,7 +1154,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
|
||||||
command_template="show bgp vpnv6 unicast vrf <vrf> neighbor out <neighbor> | one-line",
|
command_template="show bgp vpnv6 unicast vrf <vrf> neighbor out <neighbor> | one-line",
|
||||||
match=(
|
match=(
|
||||||
r"(?i)^\s*show\s+bgp\s+vpnv6\s+unicast\s+vrf\s+(?P<vrf>\S+)\s+"
|
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*$"
|
r"neighbor\s+(?P<direction>out)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
|
||||||
),
|
),
|
||||||
textfsm_command="show bgp vpnv6 unicast neighbor out",
|
textfsm_command="show bgp vpnv6 unicast neighbor out",
|
||||||
description="Per-VRF IPv6 CE advertised routes; discover pairs from BGP peer intent.",
|
description="Per-VRF IPv6 CE advertised routes; discover pairs from BGP peer intent.",
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ def apply_biz_state_schema(conn: Connection) -> None:
|
||||||
"ALTER TABLE biz_state_task ADD COLUMN IF NOT EXISTS daily_keep_count INTEGER DEFAULT 10",
|
"ALTER TABLE biz_state_task ADD COLUMN IF NOT EXISTS daily_keep_count INTEGER DEFAULT 10",
|
||||||
"ALTER TABLE biz_state_batch ADD COLUMN IF NOT EXISTS is_baseline BOOLEAN DEFAULT FALSE",
|
"ALTER TABLE biz_state_batch ADD COLUMN IF NOT EXISTS is_baseline BOOLEAN DEFAULT FALSE",
|
||||||
"ALTER TABLE biz_state_batch ADD COLUMN IF NOT EXISTS baseline_marked_at TIMESTAMP",
|
"ALTER TABLE biz_state_batch ADD COLUMN IF NOT EXISTS baseline_marked_at TIMESTAMP",
|
||||||
|
"ALTER TABLE biz_state_batch ADD COLUMN IF NOT EXISTS alias VARCHAR(128) DEFAULT ''",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_is_baseline ON biz_state_batch (is_baseline)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_is_baseline ON biz_state_batch (is_baseline)",
|
||||||
"ALTER TABLE biz_migration_batch ADD COLUMN IF NOT EXISTS accept_status VARCHAR(32) DEFAULT 'none'",
|
"ALTER TABLE biz_migration_batch ADD COLUMN IF NOT EXISTS accept_status VARCHAR(32) DEFAULT 'none'",
|
||||||
"ALTER TABLE biz_migration_batch ADD COLUMN IF NOT EXISTS accept_run_id VARCHAR(64) DEFAULT ''",
|
"ALTER TABLE biz_migration_batch ADD COLUMN IF NOT EXISTS accept_run_id VARCHAR(64) DEFAULT ''",
|
||||||
|
|
|
||||||
|
|
@ -466,6 +466,7 @@ def _batch_list_item(b: BizStateBatch, protect: dict[str, Any]) -> dict[str, Any
|
||||||
"message": b.message,
|
"message": b.message,
|
||||||
"ne_name": b.ne_name or "",
|
"ne_name": b.ne_name or "",
|
||||||
"ne_id": b.ne_id or "",
|
"ne_id": b.ne_id or "",
|
||||||
|
"alias": str(getattr(b, "alias", "") or ""),
|
||||||
"started_at": b.started_at.isoformat() + "Z" if b.started_at else None,
|
"started_at": b.started_at.isoformat() + "Z" if b.started_at else None,
|
||||||
"ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None,
|
"ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None,
|
||||||
"is_baseline": bool(getattr(b, "is_baseline", False)),
|
"is_baseline": bool(getattr(b, "is_baseline", False)),
|
||||||
|
|
@ -510,6 +511,15 @@ def set_batch_baseline(db: Session, batch_id: str, *, marked: bool) -> dict[str,
|
||||||
return _batch_list_item(b, batch_protect_info(db, batch_id))
|
return _batch_list_item(b, batch_protect_info(db, batch_id))
|
||||||
|
|
||||||
|
|
||||||
|
def set_batch_alias(db: Session, batch_id: str, *, alias: str) -> dict[str, Any]:
|
||||||
|
b = db.get(BizStateBatch, batch_id)
|
||||||
|
if not b:
|
||||||
|
raise HTTPException(status_code=404, detail="batch_not_found")
|
||||||
|
b.alias = str(alias or "").strip()[:128]
|
||||||
|
db.commit()
|
||||||
|
return _batch_list_item(b, batch_protect_info(db, batch_id))
|
||||||
|
|
||||||
|
|
||||||
def delete_batch(db: Session, batch_id: str) -> dict[str, Any]:
|
def delete_batch(db: Session, batch_id: str) -> dict[str, Any]:
|
||||||
b = db.get(BizStateBatch, batch_id)
|
b = db.get(BizStateBatch, batch_id)
|
||||||
if not b:
|
if not b:
|
||||||
|
|
@ -702,6 +712,8 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
|
||||||
}
|
}
|
||||||
for mid in sheets_order
|
for mid in sheets_order
|
||||||
]
|
]
|
||||||
|
sheet_count = len(sheets)
|
||||||
|
sheets_with_data = sum(1 for s in sheets if int(s.get("row_count") or 0) > 0)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"id": b.id,
|
"id": b.id,
|
||||||
|
|
@ -710,6 +722,7 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
|
||||||
"command_count": b.command_count,
|
"command_count": b.command_count,
|
||||||
"row_count": b.row_count,
|
"row_count": b.row_count,
|
||||||
"message": b.message,
|
"message": b.message,
|
||||||
|
"alias": str(getattr(b, "alias", "") or ""),
|
||||||
"started_at": b.started_at.isoformat() + "Z" if b.started_at else None,
|
"started_at": b.started_at.isoformat() + "Z" if b.started_at else None,
|
||||||
"ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None,
|
"ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None,
|
||||||
"is_baseline": bool(getattr(b, "is_baseline", False)),
|
"is_baseline": bool(getattr(b, "is_baseline", False)),
|
||||||
|
|
@ -718,6 +731,10 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
|
||||||
else None,
|
else None,
|
||||||
"protected": bool(protect.get("protected")),
|
"protected": bool(protect.get("protected")),
|
||||||
"protect_reasons": list(protect.get("reasons") or []),
|
"protect_reasons": list(protect.get("reasons") or []),
|
||||||
|
# Workbook header: 对比项 / 有数据 / 表格
|
||||||
|
"compare_item_count": sheet_count,
|
||||||
|
"sheets_with_data": sheets_with_data,
|
||||||
|
"sheet_count": sheet_count,
|
||||||
"commands": cmd_payload,
|
"commands": cmd_payload,
|
||||||
"sheets": sheets,
|
"sheets": sheets,
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -68,6 +68,10 @@ class BatchBaselineIn(BaseModel):
|
||||||
marked: bool = True
|
marked: bool = True
|
||||||
|
|
||||||
|
|
||||||
|
class BatchAliasIn(BaseModel):
|
||||||
|
alias: str = ""
|
||||||
|
|
||||||
|
|
||||||
class BatchBulkDeleteIn(BaseModel):
|
class BatchBulkDeleteIn(BaseModel):
|
||||||
batch_ids: list[str] = Field(default_factory=list)
|
batch_ids: list[str] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
@ -242,6 +246,13 @@ def api_set_batch_baseline(
|
||||||
return svc.set_batch_baseline(db, batch_id, marked=bool(body.marked))
|
return svc.set_batch_baseline(db, batch_id, marked=bool(body.marked))
|
||||||
|
|
||||||
|
|
||||||
|
@router.patch("/batches/{batch_id}/alias")
|
||||||
|
def api_set_batch_alias(
|
||||||
|
batch_id: str, body: BatchAliasIn, db: Session = Depends(get_db)
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
return svc.set_batch_alias(db, batch_id, alias=str(body.alias or ""))
|
||||||
|
|
||||||
|
|
||||||
@router.delete("/batches/{batch_id}")
|
@router.delete("/batches/{batch_id}")
|
||||||
def api_delete_batch(batch_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def api_delete_batch(batch_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
return svc.delete_batch(db, batch_id)
|
return svc.delete_batch(db, batch_id)
|
||||||
|
|
|
||||||
|
|
@ -7,8 +7,9 @@ Value PATH (.*)
|
||||||
|
|
||||||
Start
|
Start
|
||||||
^Routes\s+(Learned|Sent) -> Routes
|
^Routes\s+(Learned|Sent) -> Routes
|
||||||
^\s*\*?\s*Network -> Routes
|
^\s*\*?\s*Network\s+Next -> Routes
|
||||||
^Network\s+ -> Routes
|
^Network\s+Next -> Routes
|
||||||
|
^\s*Dest\s+Next -> Routes
|
||||||
^\s*$$
|
^\s*$$
|
||||||
^.* -> Start
|
^.* -> Start
|
||||||
|
|
||||||
|
|
@ -16,11 +17,15 @@ Routes
|
||||||
^Route\s+Distinguisher
|
^Route\s+Distinguisher
|
||||||
^Status\s+codes
|
^Status\s+codes
|
||||||
^Origin\s+codes
|
^Origin\s+codes
|
||||||
|
^Current\s+AS
|
||||||
^Total\s+number
|
^Total\s+number
|
||||||
^\s+Valid
|
^\s+Valid
|
||||||
^\s+Invalid
|
^\s+Invalid
|
||||||
^Local\s+router
|
^Local\s+router
|
||||||
^Remote\s+router
|
^Remote\s+router
|
||||||
|
^\s*\*?\s*Network\s+Next
|
||||||
|
^Network\s+Next
|
||||||
|
^\s*Dest\s+Next
|
||||||
^\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+${METRIC}\s+${LOC_PRF}\s+${TAG}\s+${PATH}\s*$$ -> Record
|
||||||
^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record
|
^\s*\*?\s*>?\s*${NETWORK}\s+${NEXT_HOP}\s+${PATH}\s*$$ -> Record
|
||||||
^\s*$$
|
^\s*$$
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,8 @@ class BizStateBatch(Base):
|
||||||
# Manual pin: excluded from auto-purge until revoked
|
# Manual pin: excluded from auto-purge until revoked
|
||||||
is_baseline: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
is_baseline: Mapped[bool] = mapped_column(Boolean, default=False, index=True)
|
||||||
baseline_marked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
baseline_marked_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||||
|
# Optional display name for compare / cutover pickers
|
||||||
|
alias: Mapped[str] = mapped_column(String(128), default="")
|
||||||
|
|
||||||
|
|
||||||
class BizStateBatchCommand(Base):
|
class BizStateBatchCommand(Base):
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,29 @@ Routes Learned From This Neighbor:
|
||||||
* 10.2.0.0/24 10.0.0.1 20 65001 65009 ?
|
* 10.2.0.0/24 10.0.0.1 20 65001 65009 ?
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
_BGP_ROUTE_EMPTY_DEST = """
|
||||||
|
Current AS: 100. Other AS: 24208, 3.45420
|
||||||
|
|
||||||
|
Routes Learned From This Neighbor:
|
||||||
|
Status codes: * valid, < last valid, i - internal, s - stale
|
||||||
|
Origin codes: i - IGP, e - EGP, ? - incomplete
|
||||||
|
Local router ID 1.11.1.1, Local AS 100, Local port 0
|
||||||
|
Remote router ID 0.0.0.0, Remote AS 100, Remote port 0
|
||||||
|
Total number of routes: 0
|
||||||
|
Valid routes : 0
|
||||||
|
Invalid routes : 0
|
||||||
|
Dest Next Hop Metric LocPrf InTag Path
|
||||||
|
"""
|
||||||
|
|
||||||
|
_BGP_ROUTE_EMPTY_OUT = """
|
||||||
|
Current AS: 100. Other AS: 24208, 3.45420
|
||||||
|
|
||||||
|
Routes Sent To This Neighbor:
|
||||||
|
Origin codes: i - IGP, e - EGP, ? - incomplete
|
||||||
|
Total number of routes: 0
|
||||||
|
Network Next Hop From Metric LocPrf Tag Path
|
||||||
|
"""
|
||||||
|
|
||||||
_IP_ROUTE = """
|
_IP_ROUTE = """
|
||||||
Dest Gw Interface Owner Pri Metric
|
Dest Gw Interface Owner Pri Metric
|
||||||
*> 0.0.0.0/0 10.0.0.1 smartgroup1 BGP 200 1
|
*> 0.0.0.0/0 10.0.0.1 smartgroup1 BGP 200 1
|
||||||
|
|
@ -371,6 +394,48 @@ class ZteExtendedParserTests(unittest.TestCase):
|
||||||
)
|
)
|
||||||
self.assertEqual(ra.command, "show bgp vpnv4 unicast vrf CUST_A summary | one-line")
|
self.assertEqual(ra.command, "show bgp vpnv4 unicast vrf CUST_A summary | one-line")
|
||||||
|
|
||||||
|
def test_bgp_route_empty_total_skips_header(self) -> None:
|
||||||
|
empty_in = normalize_bgp_route(
|
||||||
|
raw_text=_BGP_ROUTE_EMPTY_DEST,
|
||||||
|
command="show bgp vpnv4 unicast neighbor in 10.22.9.2 | one-line",
|
||||||
|
params={"neighbor": "10.22.9.2", "direction": "in"},
|
||||||
|
)
|
||||||
|
self.assertEqual(empty_in, [])
|
||||||
|
|
||||||
|
empty_out = normalize_bgp_route(
|
||||||
|
raw_text=_BGP_ROUTE_EMPTY_OUT,
|
||||||
|
command="show bgp vpnv4 unicast neighbor out 10.22.9.2 | one-line",
|
||||||
|
)
|
||||||
|
self.assertEqual(empty_out, [])
|
||||||
|
|
||||||
|
hit_in = match_command(
|
||||||
|
vendor_key="zte",
|
||||||
|
command="show bgp vpnv4 unicast neighbor in 10.22.9.2 | one-line",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(hit_in)
|
||||||
|
assert hit_in is not None
|
||||||
|
self.assertEqual(hit_in.profile.profile_id, "zte.bgp_vpnv4_neighbor_in")
|
||||||
|
self.assertEqual(hit_in.params.get("direction"), "in")
|
||||||
|
self.assertEqual(hit_in.params.get("neighbor"), "10.22.9.2")
|
||||||
|
|
||||||
|
hit_out = match_command(
|
||||||
|
vendor_key="zte",
|
||||||
|
command="show bgp vpnv4 unicast neighbor out 10.22.9.2 | one-line",
|
||||||
|
)
|
||||||
|
self.assertIsNotNone(hit_out)
|
||||||
|
assert hit_out is not None
|
||||||
|
self.assertEqual(hit_out.profile.profile_id, "zte.bgp_vpnv4_neighbor_out")
|
||||||
|
self.assertEqual(hit_out.params.get("direction"), "out")
|
||||||
|
self.assertEqual(hit_out.params.get("neighbor"), "10.22.9.2")
|
||||||
|
|
||||||
|
# Direction detected from command when params omit it
|
||||||
|
parsed = normalize_bgp_route(
|
||||||
|
raw_text=_BGP_ROUTE_IN,
|
||||||
|
command="show bgp vpnv4 unicast neighbor out 10.0.0.1",
|
||||||
|
)
|
||||||
|
self.assertEqual(parsed[0]["direction"], "out")
|
||||||
|
self.assertEqual(parsed[0]["neighbor"], "10.0.0.1")
|
||||||
|
|
||||||
def test_ip_ipv6_route_and_pw(self) -> None:
|
def test_ip_ipv6_route_and_pw(self) -> None:
|
||||||
ip = normalize_ip_route(
|
ip = normalize_ip_route(
|
||||||
raw_text=_IP_ROUTE,
|
raw_text=_IP_ROUTE,
|
||||||
|
|
@ -705,6 +770,7 @@ gei-0/0/0/2 is up, ifindex: 2
|
||||||
assert hit6 is not None
|
assert hit6 is not None
|
||||||
self.assertEqual(hit6.profile.profile_id, "zte.bgp_vpnv6_neighbor_in")
|
self.assertEqual(hit6.profile.profile_id, "zte.bgp_vpnv6_neighbor_in")
|
||||||
self.assertEqual(hit6.params.get("neighbor"), "FC00:1::1")
|
self.assertEqual(hit6.params.get("neighbor"), "FC00:1::1")
|
||||||
|
self.assertEqual(hit6.params.get("direction"), "in")
|
||||||
|
|
||||||
routes = normalize_bgp_route(
|
routes = normalize_bgp_route(
|
||||||
raw_text=_BGP_ROUTE_IN,
|
raw_text=_BGP_ROUTE_IN,
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,12 @@ const en = {
|
||||||
unmarkBaseline: "Unmark baseline",
|
unmarkBaseline: "Unmark baseline",
|
||||||
baseline: "Baseline",
|
baseline: "Baseline",
|
||||||
protected: "Protected",
|
protected: "Protected",
|
||||||
|
colAlias: "Alias",
|
||||||
|
setAlias: "Set alias",
|
||||||
|
aliasPrompt: "Batch alias (preferred in compare/cutover pickers; empty clears)",
|
||||||
|
aliasSaved: "Alias saved",
|
||||||
|
aliasEmpty: "Untitled",
|
||||||
|
workbookStats: "Compare {{compare}} · With data {{withData}} · Sheets {{sheets}}",
|
||||||
deleteBatch: "Delete",
|
deleteBatch: "Delete",
|
||||||
bulkDelete: "Bulk delete",
|
bulkDelete: "Bulk delete",
|
||||||
confirmDeleteBatch: "Delete this batch? Protected batches cannot be deleted.",
|
confirmDeleteBatch: "Delete this batch? Protected batches cannot be deleted.",
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,12 @@ const zh = {
|
||||||
unmarkBaseline: "撤销基线",
|
unmarkBaseline: "撤销基线",
|
||||||
baseline: "基线",
|
baseline: "基线",
|
||||||
protected: "已保护",
|
protected: "已保护",
|
||||||
|
colAlias: "别名",
|
||||||
|
setAlias: "设别名",
|
||||||
|
aliasPrompt: "批次别名(比对/割接选择时优先显示;留空清除)",
|
||||||
|
aliasSaved: "别名已保存",
|
||||||
|
aliasEmpty: "未命名",
|
||||||
|
workbookStats: "对比项 {{compare}} · 有数据 {{withData}} · 表格 {{sheets}}",
|
||||||
deleteBatch: "删除",
|
deleteBatch: "删除",
|
||||||
bulkDelete: "批量删除",
|
bulkDelete: "批量删除",
|
||||||
confirmDeleteBatch: "删除该采集批次?受保护的批次无法删除。",
|
confirmDeleteBatch: "删除该采集批次?受保护的批次无法删除。",
|
||||||
|
|
|
||||||
|
|
@ -9953,6 +9953,31 @@ html.login-page--paused .login-page__flare {
|
||||||
gap: 8px 12px;
|
gap: 8px 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bs-alias-link {
|
||||||
|
appearance: none;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
color: #93c5fd;
|
||||||
|
cursor: pointer;
|
||||||
|
text-align: left;
|
||||||
|
max-width: 160px;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-alias-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-alias-link:disabled {
|
||||||
|
cursor: default;
|
||||||
|
opacity: 0.6;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
.bs-id-row {
|
.bs-id-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
@ -14297,8 +14322,8 @@ option {
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-heroui-modal .pt-list-table.bs-profiles-table {
|
.app-heroui-modal .pt-list-table.bs-profiles-table {
|
||||||
width: max-content !important;
|
width: 100% !important;
|
||||||
min-width: max(100%, 1280px) !important;
|
min-width: 960px !important;
|
||||||
table-layout: fixed !important;
|
table-layout: fixed !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -40,7 +40,13 @@ type CreateJobStep = 0 | 1 | 2 | 3;
|
||||||
const CREATE_JOB_STEPS = 4;
|
const CREATE_JOB_STEPS = 4;
|
||||||
|
|
||||||
type TaskOpt = { id: string; ne_name: string; ne_ip: string; vendor: string };
|
type TaskOpt = { id: string; ne_name: string; ne_ip: string; vendor: string };
|
||||||
type BatchOpt = { id: string; status: string; row_count: number; started_at?: string | null };
|
type BatchOpt = {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
row_count: number;
|
||||||
|
started_at?: string | null;
|
||||||
|
alias?: string;
|
||||||
|
};
|
||||||
type MetricField = {
|
type MetricField = {
|
||||||
name: string;
|
name: string;
|
||||||
display_name: string;
|
display_name: string;
|
||||||
|
|
@ -306,6 +312,14 @@ function taskLabel(row: TaskOpt) {
|
||||||
return `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`;
|
return `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function batchOptLabel(b: BatchOpt) {
|
||||||
|
const alias = String(b.alias || "").trim();
|
||||||
|
const when = fmtTime(b.started_at);
|
||||||
|
const tail = `${b.status} · rows=${b.row_count}`;
|
||||||
|
if (alias) return `${alias} · ${when} · ${tail}`;
|
||||||
|
return `${when} · ${tail}`;
|
||||||
|
}
|
||||||
|
|
||||||
function sheetIdentity(s: { sheet_id?: string; metric_id?: string } | null | undefined): string {
|
function sheetIdentity(s: { sheet_id?: string; metric_id?: string } | null | undefined): string {
|
||||||
return String(s?.sheet_id || s?.metric_id || "").trim();
|
return String(s?.sheet_id || s?.metric_id || "").trim();
|
||||||
}
|
}
|
||||||
|
|
@ -1929,7 +1943,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<option value="">{t("bizCompare.pick")}</option>
|
<option value="">{t("bizCompare.pick")}</option>
|
||||||
{beforeBatches.map((b) => (
|
{beforeBatches.map((b) => (
|
||||||
<option key={b.id} value={b.id}>
|
<option key={b.id} value={b.id}>
|
||||||
{fmtTime(b.started_at)} · {b.status} · rows={b.row_count}
|
{batchOptLabel(b)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
|
|
@ -1956,7 +1970,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<option value="">{t("bizCompare.pick")}</option>
|
<option value="">{t("bizCompare.pick")}</option>
|
||||||
{afterBatches.map((b) => (
|
{afterBatches.map((b) => (
|
||||||
<option key={b.id} value={b.id}>
|
<option key={b.id} value={b.id}>
|
||||||
{fmtTime(b.started_at)} · {b.status} · rows={b.row_count}
|
{batchOptLabel(b)}
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
|
|
|
||||||
|
|
@ -382,8 +382,12 @@ export function BizMigrationPage() {
|
||||||
const [createHfEndAt, setCreateHfEndAt] = useState("");
|
const [createHfEndAt, setCreateHfEndAt] = useState("");
|
||||||
const [createOldBaselineId, setCreateOldBaselineId] = useState("");
|
const [createOldBaselineId, setCreateOldBaselineId] = useState("");
|
||||||
const [createNewBaselineId, setCreateNewBaselineId] = useState("");
|
const [createNewBaselineId, setCreateNewBaselineId] = useState("");
|
||||||
const [createOldBatches, setCreateOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
|
const [createOldBatches, setCreateOldBatches] = useState<
|
||||||
const [createNewBatches, setCreateNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
|
{ id: string; started_at?: string | null; alias?: string }[]
|
||||||
|
>([]);
|
||||||
|
const [createNewBatches, setCreateNewBatches] = useState<
|
||||||
|
{ id: string; started_at?: string | null; alias?: string }[]
|
||||||
|
>([]);
|
||||||
const [createOldHasPortrait, setCreateOldHasPortrait] = useState(false);
|
const [createOldHasPortrait, setCreateOldHasPortrait] = useState(false);
|
||||||
const [createNewHasPortrait, setCreateNewHasPortrait] = useState(false);
|
const [createNewHasPortrait, setCreateNewHasPortrait] = useState(false);
|
||||||
const [monitorTpls, setMonitorTpls] = useState<MonitorTplOpt[]>([]);
|
const [monitorTpls, setMonitorTpls] = useState<MonitorTplOpt[]>([]);
|
||||||
|
|
@ -402,10 +406,10 @@ export function BizMigrationPage() {
|
||||||
const [hfEndAt, setHfEndAt] = useState("");
|
const [hfEndAt, setHfEndAt] = useState("");
|
||||||
const [oldBaselineId, setOldBaselineId] = useState("");
|
const [oldBaselineId, setOldBaselineId] = useState("");
|
||||||
const [newBaselineId, setNewBaselineId] = useState("");
|
const [newBaselineId, setNewBaselineId] = useState("");
|
||||||
const [oldBatches, setOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
|
const [oldBatches, setOldBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]);
|
||||||
const [newBatches, setNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
|
const [newBatches, setNewBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]);
|
||||||
const [hfOldBatches, setHfOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
|
const [hfOldBatches, setHfOldBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]);
|
||||||
const [hfNewBatches, setHfNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
|
const [hfNewBatches, setHfNewBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]);
|
||||||
const [batches, setBatches] = useState<MigBatch[]>([]);
|
const [batches, setBatches] = useState<MigBatch[]>([]);
|
||||||
const [batchId, setBatchId] = useState("");
|
const [batchId, setBatchId] = useState("");
|
||||||
const [board, setBoard] = useState<{
|
const [board, setBoard] = useState<{
|
||||||
|
|
@ -751,6 +755,7 @@ export function BizMigrationPage() {
|
||||||
(r.batches || []).map((x) => ({
|
(r.batches || []).map((x) => ({
|
||||||
id: String(x.id || ""),
|
id: String(x.id || ""),
|
||||||
started_at: x.started_at || null,
|
started_at: x.started_at || null,
|
||||||
|
alias: String(x.alias || ""),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -773,6 +778,7 @@ export function BizMigrationPage() {
|
||||||
(r.batches || []).map((x) => ({
|
(r.batches || []).map((x) => ({
|
||||||
id: String(x.id || ""),
|
id: String(x.id || ""),
|
||||||
started_at: x.started_at || null,
|
started_at: x.started_at || null,
|
||||||
|
alias: String(x.alias || ""),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -788,6 +794,7 @@ export function BizMigrationPage() {
|
||||||
((r.items || []) as Record<string, unknown>[]).map((x) => ({
|
((r.items || []) as Record<string, unknown>[]).map((x) => ({
|
||||||
id: String(x.id || ""),
|
id: String(x.id || ""),
|
||||||
started_at: (x.started_at as string) || null,
|
started_at: (x.started_at as string) || null,
|
||||||
|
alias: String(x.alias || ""),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -803,6 +810,7 @@ export function BizMigrationPage() {
|
||||||
((r.items || []) as Record<string, unknown>[]).map((x) => ({
|
((r.items || []) as Record<string, unknown>[]).map((x) => ({
|
||||||
id: String(x.id || ""),
|
id: String(x.id || ""),
|
||||||
started_at: (x.started_at as string) || null,
|
started_at: (x.started_at as string) || null,
|
||||||
|
alias: String(x.alias || ""),
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -820,13 +828,17 @@ export function BizMigrationPage() {
|
||||||
}
|
}
|
||||||
void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => {
|
void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const items: { id: string; started_at?: string | null }[] = [];
|
const items: { id: string; started_at?: string | null; alias?: string }[] = [];
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
for (const x of (r.items || []) as Record<string, unknown>[]) {
|
for (const x of (r.items || []) as Record<string, unknown>[]) {
|
||||||
const id = String(x.id || "");
|
const id = String(x.id || "");
|
||||||
if (!id || seen.has(id)) continue;
|
if (!id || seen.has(id)) continue;
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
items.push({ id, started_at: (x.started_at as string) || null });
|
items.push({
|
||||||
|
id,
|
||||||
|
started_at: (x.started_at as string) || null,
|
||||||
|
alias: String(x.alias || ""),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setHfOldBatches(items);
|
setHfOldBatches(items);
|
||||||
|
|
@ -845,13 +857,17 @@ export function BizMigrationPage() {
|
||||||
}
|
}
|
||||||
void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => {
|
void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
const items: { id: string; started_at?: string | null }[] = [];
|
const items: { id: string; started_at?: string | null; alias?: string }[] = [];
|
||||||
for (const r of results) {
|
for (const r of results) {
|
||||||
for (const x of (r.items || []) as Record<string, unknown>[]) {
|
for (const x of (r.items || []) as Record<string, unknown>[]) {
|
||||||
const id = String(x.id || "");
|
const id = String(x.id || "");
|
||||||
if (!id || seen.has(id)) continue;
|
if (!id || seen.has(id)) continue;
|
||||||
seen.add(id);
|
seen.add(id);
|
||||||
items.push({ id, started_at: (x.started_at as string) || null });
|
items.push({
|
||||||
|
id,
|
||||||
|
started_at: (x.started_at as string) || null,
|
||||||
|
alias: String(x.alias || ""),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
setHfNewBatches(items);
|
setHfNewBatches(items);
|
||||||
|
|
@ -1424,12 +1440,19 @@ export function BizMigrationPage() {
|
||||||
return [...set];
|
return [...set];
|
||||||
}, [monitorTpls, createMonitorTplId, createCollectMetricIds]);
|
}, [monitorTpls, createMonitorTplId, createCollectMetricIds]);
|
||||||
|
|
||||||
const batchOptions = (items: { id: string; started_at?: string | null }[]) => (
|
const batchOptions = (items: { id: string; started_at?: string | null; alias?: string }[]) => (
|
||||||
<>
|
<>
|
||||||
<option value="">—</option>
|
<option value="">—</option>
|
||||||
{items.map((x) => {
|
{items.map((x) => {
|
||||||
|
const alias = String(x.alias || "").trim();
|
||||||
const when = fmtTime(x.started_at);
|
const when = fmtTime(x.started_at);
|
||||||
const label = when !== "—" ? `${when} · ${x.id}` : x.id;
|
const label = alias
|
||||||
|
? when !== "—"
|
||||||
|
? `${alias} · ${when}`
|
||||||
|
: alias
|
||||||
|
: when !== "—"
|
||||||
|
? `${when} · ${x.id}`
|
||||||
|
: x.id;
|
||||||
return (
|
return (
|
||||||
<option key={x.id} value={x.id} title={x.id}>
|
<option key={x.id} value={x.id} title={x.id}>
|
||||||
{label}
|
{label}
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,13 @@ import {
|
||||||
bizStatePauseTask,
|
bizStatePauseTask,
|
||||||
bizStatePurgeTask,
|
bizStatePurgeTask,
|
||||||
bizStateSetBatchBaseline,
|
bizStateSetBatchBaseline,
|
||||||
|
bizStateSetBatchAlias,
|
||||||
bizStateSetBindings,
|
bizStateSetBindings,
|
||||||
bizStateStartTask,
|
bizStateStartTask,
|
||||||
fetchCliTargets,
|
fetchCliTargets,
|
||||||
formatErr,
|
formatErr,
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
|
|
||||||
import type { CliTargetItem } from "../../types";
|
import type { CliTargetItem } from "../../types";
|
||||||
import { pageCount } from "../../utils/display";
|
import { pageCount } from "../../utils/display";
|
||||||
import { writeClipboardText } from "../../utils/clipboard";
|
import { writeClipboardText } from "../../utils/clipboard";
|
||||||
|
|
@ -76,6 +78,7 @@ type BatchRow = {
|
||||||
is_baseline?: boolean;
|
is_baseline?: boolean;
|
||||||
protected?: boolean;
|
protected?: boolean;
|
||||||
protect_reasons?: string[];
|
protect_reasons?: string[];
|
||||||
|
alias?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Candidate = {
|
type Candidate = {
|
||||||
|
|
@ -617,6 +620,24 @@ export function BizStatePage() {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const editBatchAlias = async (batchId: string, current?: string) => {
|
||||||
|
const next = window.prompt(t("bizState.aliasPrompt"), String(current || ""));
|
||||||
|
if (next === null) return;
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
await bizStateSetBatchAlias(batchId, next.trim());
|
||||||
|
showOk(t("bizState.aliasSaved"));
|
||||||
|
if (taskId) await loadTask(taskId);
|
||||||
|
if (batchDetail?.id === batchId) {
|
||||||
|
setBatchDetail((prev: any) => (prev ? { ...prev, alias: next.trim() } : prev));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError(formatErr(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const removeBatch = async (batchId: string) => {
|
const removeBatch = async (batchId: string) => {
|
||||||
if (!window.confirm(t("bizState.confirmDeleteBatch"))) return;
|
if (!window.confirm(t("bizState.confirmDeleteBatch"))) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|
@ -1528,6 +1549,7 @@ export function BizStatePage() {
|
||||||
<tr>
|
<tr>
|
||||||
<th style={{ width: 36 }} />
|
<th style={{ width: 36 }} />
|
||||||
<th>{t("bizState.colTime")}</th>
|
<th>{t("bizState.colTime")}</th>
|
||||||
|
<th>{t("bizState.colAlias")}</th>
|
||||||
<th>{t("bizState.colStatus")}</th>
|
<th>{t("bizState.colStatus")}</th>
|
||||||
<th>{t("bizState.colProtect")}</th>
|
<th>{t("bizState.colProtect")}</th>
|
||||||
<th>{t("bizState.colRows")}</th>
|
<th>{t("bizState.colRows")}</th>
|
||||||
|
|
@ -1548,6 +1570,17 @@ export function BizStatePage() {
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
<td className="pt-list-time">{fmtTime(b.started_at)}</td>
|
<td className="pt-list-time">{fmtTime(b.started_at)}</td>
|
||||||
|
<td>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="bs-alias-link"
|
||||||
|
disabled={busy}
|
||||||
|
title={t("bizState.setAlias")}
|
||||||
|
onClick={() => void editBatchAlias(b.id, b.alias)}
|
||||||
|
>
|
||||||
|
{b.alias?.trim() ? b.alias : t("bizState.aliasEmpty")}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<NmStatusChip color={jobChipColor(b.status)}>{b.status}</NmStatusChip>
|
<NmStatusChip color={jobChipColor(b.status)}>{b.status}</NmStatusChip>
|
||||||
</td>
|
</td>
|
||||||
|
|
@ -1572,6 +1605,14 @@ export function BizStatePage() {
|
||||||
<Button size="sm" variant="ghost" onPress={() => void bizStateDownloadExport(b.id)}>
|
<Button size="sm" variant="ghost" onPress={() => void bizStateDownloadExport(b.id)}>
|
||||||
{t("bizState.export")}
|
{t("bizState.export")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
isDisabled={busy}
|
||||||
|
onPress={() => void editBatchAlias(b.id, b.alias)}
|
||||||
|
>
|
||||||
|
{t("bizState.setAlias")}
|
||||||
|
</Button>
|
||||||
{b.is_baseline ? (
|
{b.is_baseline ? (
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|
@ -1606,7 +1647,7 @@ export function BizStatePage() {
|
||||||
})}
|
})}
|
||||||
{!batches.length ? (
|
{!batches.length ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={6}>
|
<td colSpan={7}>
|
||||||
<div className="pt-list-empty">{t("bizState.noBatches")}</div>
|
<div className="pt-list-empty">{t("bizState.noBatches")}</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
|
@ -1791,7 +1832,16 @@ export function BizStatePage() {
|
||||||
<NmStatusChip color={jobChipColor(String(batchDetail.status || ""))}>
|
<NmStatusChip color={jobChipColor(String(batchDetail.status || ""))}>
|
||||||
{String(batchDetail.status || "—")}
|
{String(batchDetail.status || "—")}
|
||||||
</NmStatusChip>
|
</NmStatusChip>
|
||||||
|
{batchDetail.alias ? (
|
||||||
|
<NmStatusChip color="accent">{String(batchDetail.alias)}</NmStatusChip>
|
||||||
|
) : null}
|
||||||
<span className="muted">
|
<span className="muted">
|
||||||
|
{t("bizState.workbookStats", {
|
||||||
|
compare: Number(batchDetail.compare_item_count ?? batchDetail.sheet_count ?? 0),
|
||||||
|
withData: Number(batchDetail.sheets_with_data ?? 0),
|
||||||
|
sheets: Number(batchDetail.sheet_count ?? 0),
|
||||||
|
})}
|
||||||
|
{" · "}
|
||||||
{batchDetail.command_count} cmd · {batchDetail.row_count} rows ·{" "}
|
{batchDetail.command_count} cmd · {batchDetail.row_count} rows ·{" "}
|
||||||
{fmtTime(batchDetail.started_at)}
|
{fmtTime(batchDetail.started_at)}
|
||||||
</span>
|
</span>
|
||||||
|
|
@ -1812,6 +1862,14 @@ export function BizStatePage() {
|
||||||
>
|
>
|
||||||
{t("bizState.copyBatchId")}
|
{t("bizState.copyBatchId")}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
isDisabled={busy}
|
||||||
|
onPress={() => void editBatchAlias(String(batchDetail.id || ""), String(batchDetail.alias || ""))}
|
||||||
|
>
|
||||||
|
{t("bizState.setAlias")}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -1837,6 +1837,12 @@ export const bizStateSetBatchBaseline = (batchId: string, marked: boolean) =>
|
||||||
{ marked },
|
{ marked },
|
||||||
);
|
);
|
||||||
|
|
||||||
|
export const bizStateSetBatchAlias = (batchId: string, alias: string) =>
|
||||||
|
apiPatch<Record<string, unknown>>(
|
||||||
|
`/v1/biz-state/batches/${encodeURIComponent(batchId)}/alias`,
|
||||||
|
{ alias },
|
||||||
|
);
|
||||||
|
|
||||||
export const bizStateDeleteBatch = (batchId: string) =>
|
export const bizStateDeleteBatch = (batchId: string) =>
|
||||||
apiDelete<{ ok: boolean }>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`);
|
apiDelete<{ ok: boolean }>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`);
|
||||||
|
|
||||||
|
|
@ -2063,7 +2069,13 @@ export const bizMigrationNePortrait = (params: { source: string; ne_id: string;
|
||||||
source: string;
|
source: string;
|
||||||
ne_id: string;
|
ne_id: string;
|
||||||
task: Record<string, unknown> | null;
|
task: Record<string, unknown> | null;
|
||||||
batches: Array<{ id: string; started_at?: string | null; status?: string; row_count?: number }>;
|
batches: Array<{
|
||||||
|
id: string;
|
||||||
|
started_at?: string | null;
|
||||||
|
status?: string;
|
||||||
|
row_count?: number;
|
||||||
|
alias?: string;
|
||||||
|
}>;
|
||||||
hint?: string;
|
hint?: string;
|
||||||
}>(`/v1/biz-migration/ne-portrait?${q.toString()}`);
|
}>(`/v1/biz-migration/ne-portrait?${q.toString()}`);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue