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:
oliver 2026-09-22 10:58:57 +08:00
parent f321413dbf
commit 1fcd613bc6
16 changed files with 327 additions and 39 deletions

View file

@ -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]:
b = db.get(BizStateBatch, batch_id) if batch_id else None
if not b:
return {"id": batch_id or "", "status": "", "started_at": None}
return {"id": batch_id or "", "status": "", "started_at": None, "alias": ""}
return {
"id": b.id,
"status": b.status,
"started_at": b.started_at.isoformat() if b.started_at else None,
"row_count": b.row_count,
"alias": str(getattr(b, "alias", "") or ""),
}

View file

@ -20,6 +20,24 @@ _ROUTE_RE = re.compile(
)
_DIR_RE = re.compile(r"(?i)\bneighbor\s+(in|out)\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:
@ -36,15 +54,24 @@ def _detect_neighbor(command: str, params: dict[str, str] | None) -> str:
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]:
"""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:
@ -62,6 +89,12 @@ def _split_rest(rest: str) -> tuple[str, str, str, str]:
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(
rows: list[dict[str, Any]],
*,
@ -74,7 +107,10 @@ def _map_fsm_rows(
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:
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
seen.add(net)
path = row_get(r, "PATH", "path")
@ -85,7 +121,7 @@ def _map_fsm_rows(
"neighbor": neighbor[:128],
"direction": direction[:8],
"network": net[:128],
"next_hop": row_get(r, "NEXT_HOP", "next_hop")[:128],
"next_hop": nh[: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],
@ -115,19 +151,21 @@ def _hand_parse(
if not line.strip():
continue
low = line.strip().lower()
if low.startswith("network") or "next hop" in low:
if low.startswith(("network", "dest ", "destination")):
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
if low.startswith("local ") or low.startswith("remote ") or low.startswith("total "):
continue
if low.startswith("route distinguisher"):
if low.startswith("route distinguisher") or low.startswith("valid ") or low.startswith("invalid "):
continue
m = _ROUTE_RE.match(line)
if not m:
continue
net = m.group("net")
if net in seen:
if not _looks_like_prefix(net) or net in seen:
continue
seen.add(net)
metric, loc, tag, path = _split_rest(m.group("rest"))
@ -165,6 +203,9 @@ def normalize_bgp_route(
vrf = _detect_vrf(command, params)
neighbor = _detect_neighbor(command, params)
direction = _detect_direction(command, params)
if _empty_if_total_zero(raw_text):
return []
tables = dict(fsm_tables or {})
if not any(tables.get(k) for k in RULE_KEYS):
platform = resolve_cli_platform(

View file

@ -965,7 +965,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
parser_id="bgp_route",
title="BGP VPNv4 Neighbor In",
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",
description="Routes learned from VPNv4 neighbor; summary aux for peer state.",
placeholders=[_BGP_NEIGHBOR_VPNV4],
@ -990,7 +990,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
parser_id="bgp_route",
title="BGP VPNv4 Neighbor Out",
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",
description="Routes advertised to VPNv4 neighbor (large; bind neighbor).",
placeholders=[_BGP_NEIGHBOR_VPNV4],
@ -1016,7 +1016,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
parser_id="bgp_route",
title="BGP VPNv6 Neighbor In",
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",
description="Routes learned from VPNv6 neighbor; summary aux for peer state.",
placeholders=[_BGP_NEIGHBOR_VPNV6],
@ -1041,7 +1041,7 @@ def _zte_status_profiles() -> list[ParseProfile]:
parser_id="bgp_route",
title="BGP VPNv6 Neighbor Out",
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",
description="Routes advertised to VPNv6 neighbor (large; bind neighbor).",
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",
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*$"
r"neighbor\s+(?P<direction>in)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
),
textfsm_command="show bgp vpnv4 unicast neighbor in",
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",
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*$"
r"neighbor\s+(?P<direction>out)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
),
textfsm_command="show bgp vpnv4 unicast neighbor out",
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",
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*$"
r"neighbor\s+(?P<direction>in)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
),
textfsm_command="show bgp vpnv6 unicast neighbor in",
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",
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*$"
r"neighbor\s+(?P<direction>out)\s+(?P<neighbor>\S+)(?:\s*\|\s*one-line)?\s*$"
),
textfsm_command="show bgp vpnv6 unicast neighbor out",
description="Per-VRF IPv6 CE advertised routes; discover pairs from BGP peer intent.",

View file

@ -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_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 alias VARCHAR(128) DEFAULT ''",
"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_run_id VARCHAR(64) DEFAULT ''",

View file

@ -466,6 +466,7 @@ def _batch_list_item(b: BizStateBatch, protect: dict[str, Any]) -> dict[str, Any
"message": b.message,
"ne_name": b.ne_name 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,
"ended_at": b.ended_at.isoformat() + "Z" if b.ended_at else None,
"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))
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]:
b = db.get(BizStateBatch, batch_id)
if not b:
@ -702,6 +712,8 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
}
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 {
"id": b.id,
@ -710,6 +722,7 @@ def get_batch(db: Session, batch_id: str) -> dict[str, Any]:
"command_count": b.command_count,
"row_count": b.row_count,
"message": b.message,
"alias": str(getattr(b, "alias", "") or ""),
"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,
"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,
"protected": bool(protect.get("protected")),
"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,
"sheets": sheets,
}

View file

@ -68,6 +68,10 @@ class BatchBaselineIn(BaseModel):
marked: bool = True
class BatchAliasIn(BaseModel):
alias: str = ""
class BatchBulkDeleteIn(BaseModel):
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))
@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}")
def api_delete_batch(batch_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return svc.delete_batch(db, batch_id)

View file

@ -7,8 +7,9 @@ Value PATH (.*)
Start
^Routes\s+(Learned|Sent) -> Routes
^\s*\*?\s*Network -> Routes
^Network\s+ -> Routes
^\s*\*?\s*Network\s+Next -> Routes
^Network\s+Next -> Routes
^\s*Dest\s+Next -> Routes
^\s*$$
^.* -> Start
@ -16,11 +17,15 @@ Routes
^Route\s+Distinguisher
^Status\s+codes
^Origin\s+codes
^Current\s+AS
^Total\s+number
^\s+Valid
^\s+Invalid
^Local\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+${PATH}\s*$$ -> Record
^\s*$$

View file

@ -97,6 +97,8 @@ class BizStateBatch(Base):
# Manual pin: excluded from auto-purge until revoked
is_baseline: Mapped[bool] = mapped_column(Boolean, default=False, index=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):

View file

@ -108,6 +108,29 @@ Routes Learned From This Neighbor:
* 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 = """
Dest Gw Interface Owner Pri Metric
*> 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")
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:
ip = normalize_ip_route(
raw_text=_IP_ROUTE,
@ -705,6 +770,7 @@ gei-0/0/0/2 is up, ifindex: 2
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")
self.assertEqual(hit6.params.get("direction"), "in")
routes = normalize_bgp_route(
raw_text=_BGP_ROUTE_IN,

View file

@ -215,6 +215,12 @@ const en = {
unmarkBaseline: "Unmark baseline",
baseline: "Baseline",
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",
bulkDelete: "Bulk delete",
confirmDeleteBatch: "Delete this batch? Protected batches cannot be deleted.",

View file

@ -215,6 +215,12 @@ const zh = {
unmarkBaseline: "撤销基线",
baseline: "基线",
protected: "已保护",
colAlias: "别名",
setAlias: "设别名",
aliasPrompt: "批次别名(比对/割接选择时优先显示;留空清除)",
aliasSaved: "别名已保存",
aliasEmpty: "未命名",
workbookStats: "对比项 {{compare}} · 有数据 {{withData}} · 表格 {{sheets}}",
deleteBatch: "删除",
bulkDelete: "批量删除",
confirmDeleteBatch: "删除该采集批次?受保护的批次无法删除。",

View file

@ -9953,6 +9953,31 @@ html.login-page--paused .login-page__flare {
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 {
display: flex;
flex-wrap: wrap;
@ -14297,8 +14322,8 @@ option {
}
.app-heroui-modal .pt-list-table.bs-profiles-table {
width: max-content !important;
min-width: max(100%, 1280px) !important;
width: 100% !important;
min-width: 960px !important;
table-layout: fixed !important;
}

View file

@ -40,7 +40,13 @@ type CreateJobStep = 0 | 1 | 2 | 3;
const CREATE_JOB_STEPS = 4;
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 = {
name: string;
display_name: string;
@ -306,6 +312,14 @@ function taskLabel(row: TaskOpt) {
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 {
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>
{beforeBatches.map((b) => (
<option key={b.id} value={b.id}>
{fmtTime(b.started_at)} · {b.status} · rows={b.row_count}
{batchOptLabel(b)}
</option>
))}
</FieldSelect>
@ -1956,7 +1970,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<option value="">{t("bizCompare.pick")}</option>
{afterBatches.map((b) => (
<option key={b.id} value={b.id}>
{fmtTime(b.started_at)} · {b.status} · rows={b.row_count}
{batchOptLabel(b)}
</option>
))}
</FieldSelect>

View file

@ -382,8 +382,12 @@ export function BizMigrationPage() {
const [createHfEndAt, setCreateHfEndAt] = useState("");
const [createOldBaselineId, setCreateOldBaselineId] = useState("");
const [createNewBaselineId, setCreateNewBaselineId] = useState("");
const [createOldBatches, setCreateOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
const [createNewBatches, setCreateNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
const [createOldBatches, setCreateOldBatches] = useState<
{ 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 [createNewHasPortrait, setCreateNewHasPortrait] = useState(false);
const [monitorTpls, setMonitorTpls] = useState<MonitorTplOpt[]>([]);
@ -402,10 +406,10 @@ export function BizMigrationPage() {
const [hfEndAt, setHfEndAt] = useState("");
const [oldBaselineId, setOldBaselineId] = useState("");
const [newBaselineId, setNewBaselineId] = useState("");
const [oldBatches, setOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
const [newBatches, setNewBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
const [hfOldBatches, setHfOldBatches] = useState<{ id: string; started_at?: string | null }[]>([]);
const [hfNewBatches, setHfNewBatches] = 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; alias?: string }[]>([]);
const [hfOldBatches, setHfOldBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]);
const [hfNewBatches, setHfNewBatches] = useState<{ id: string; started_at?: string | null; alias?: string }[]>([]);
const [batches, setBatches] = useState<MigBatch[]>([]);
const [batchId, setBatchId] = useState("");
const [board, setBoard] = useState<{
@ -751,6 +755,7 @@ export function BizMigrationPage() {
(r.batches || []).map((x) => ({
id: String(x.id || ""),
started_at: x.started_at || null,
alias: String(x.alias || ""),
})),
);
});
@ -773,6 +778,7 @@ export function BizMigrationPage() {
(r.batches || []).map((x) => ({
id: String(x.id || ""),
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) => ({
id: String(x.id || ""),
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) => ({
id: String(x.id || ""),
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) => {
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 x of (r.items || []) as Record<string, unknown>[]) {
const id = String(x.id || "");
if (!id || seen.has(id)) continue;
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);
@ -845,13 +857,17 @@ export function BizMigrationPage() {
}
void Promise.all(uniq.map((tid) => bizStateListBatches(tid, 30))).then((results) => {
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 x of (r.items || []) as Record<string, unknown>[]) {
const id = String(x.id || "");
if (!id || seen.has(id)) continue;
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);
@ -1424,12 +1440,19 @@ export function BizMigrationPage() {
return [...set];
}, [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>
{items.map((x) => {
const alias = String(x.alias || "").trim();
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 (
<option key={x.id} value={x.id} title={x.id}>
{label}

View file

@ -25,11 +25,13 @@ import {
bizStatePauseTask,
bizStatePurgeTask,
bizStateSetBatchBaseline,
bizStateSetBatchAlias,
bizStateSetBindings,
bizStateStartTask,
fetchCliTargets,
formatErr,
} from "../../services/api";
import type { CliTargetItem } from "../../types";
import { pageCount } from "../../utils/display";
import { writeClipboardText } from "../../utils/clipboard";
@ -76,6 +78,7 @@ type BatchRow = {
is_baseline?: boolean;
protected?: boolean;
protect_reasons?: string[];
alias?: string;
};
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) => {
if (!window.confirm(t("bizState.confirmDeleteBatch"))) return;
setBusy(true);
@ -1528,6 +1549,7 @@ export function BizStatePage() {
<tr>
<th style={{ width: 36 }} />
<th>{t("bizState.colTime")}</th>
<th>{t("bizState.colAlias")}</th>
<th>{t("bizState.colStatus")}</th>
<th>{t("bizState.colProtect")}</th>
<th>{t("bizState.colRows")}</th>
@ -1548,6 +1570,17 @@ export function BizStatePage() {
/>
</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>
<NmStatusChip color={jobChipColor(b.status)}>{b.status}</NmStatusChip>
</td>
@ -1572,6 +1605,14 @@ export function BizStatePage() {
<Button size="sm" variant="ghost" onPress={() => void bizStateDownloadExport(b.id)}>
{t("bizState.export")}
</Button>
<Button
size="sm"
variant="ghost"
isDisabled={busy}
onPress={() => void editBatchAlias(b.id, b.alias)}
>
{t("bizState.setAlias")}
</Button>
{b.is_baseline ? (
<Button
size="sm"
@ -1606,7 +1647,7 @@ export function BizStatePage() {
})}
{!batches.length ? (
<tr>
<td colSpan={6}>
<td colSpan={7}>
<div className="pt-list-empty">{t("bizState.noBatches")}</div>
</td>
</tr>
@ -1791,7 +1832,16 @@ export function BizStatePage() {
<NmStatusChip color={jobChipColor(String(batchDetail.status || ""))}>
{String(batchDetail.status || "—")}
</NmStatusChip>
{batchDetail.alias ? (
<NmStatusChip color="accent">{String(batchDetail.alias)}</NmStatusChip>
) : null}
<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 ·{" "}
{fmtTime(batchDetail.started_at)}
</span>
@ -1812,6 +1862,14 @@ export function BizStatePage() {
>
{t("bizState.copyBatchId")}
</Button>
<Button
size="sm"
variant="ghost"
isDisabled={busy}
onPress={() => void editBatchAlias(String(batchDetail.id || ""), String(batchDetail.alias || ""))}
>
{t("bizState.setAlias")}
</Button>
</div>
</div>
) : null}

View file

@ -1837,6 +1837,12 @@ export const bizStateSetBatchBaseline = (batchId: string, marked: boolean) =>
{ 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) =>
apiDelete<{ ok: boolean }>(`/v1/biz-state/batches/${encodeURIComponent(batchId)}`);
@ -2063,7 +2069,13 @@ export const bizMigrationNePortrait = (params: { source: string; ne_id: string;
source: string;
ne_id: string;
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;
}>(`/v1/biz-migration/ne-portrait?${q.toString()}`);
};