mirror of
https://github.com/hansjone/netx.git
synced 2026-10-12 11:43:21 +08:00
Make biz compare async and cutover-friendly for large tables.
Run compares in the background with progress, persist fails first, and store success as sampled key+row_id hydrated from source so million-row sheets no longer block the API. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
3e2cb4165c
commit
227959b9a2
10 changed files with 1035 additions and 168 deletions
|
|
@ -105,6 +105,9 @@ def compare_rows(
|
||||||
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,
|
iface_normalize_rules: Sequence[Mapping[str, str]] | None = None,
|
||||||
ignore_port_changes: bool | None = None,
|
ignore_port_changes: bool | None = None,
|
||||||
|
include_unchanged: bool = False,
|
||||||
|
unchanged_limit: int | None = None,
|
||||||
|
compact_unchanged: bool = False,
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
"""Return summary + diffs list.
|
"""Return summary + diffs list.
|
||||||
|
|
||||||
|
|
@ -112,6 +115,14 @@ def compare_rows(
|
||||||
|
|
||||||
Pipeline: iface normalize (both sides) → port map (before) → match.
|
Pipeline: iface normalize (both sides) → port map (before) → match.
|
||||||
|
|
||||||
|
``include_unchanged``: when False, matching rows still increment
|
||||||
|
``summary.unchanged`` but are omitted from ``diffs``.
|
||||||
|
|
||||||
|
``unchanged_limit``: max unchanged diffs to emit (None = no cap). Use with
|
||||||
|
large sheets so the UI can browse a sample without writing millions of rows.
|
||||||
|
|
||||||
|
``compact_unchanged``: emit key only (empty before/after) to cut storage.
|
||||||
|
|
||||||
``ignore_port_changes``:
|
``ignore_port_changes``:
|
||||||
- ``None`` (default): auto — drop iface from match key only when remaining
|
- ``None`` (default): auto — drop iface from match key only when remaining
|
||||||
keys stay unique on both sides (legacy LLDP-style heuristic).
|
keys stay unique on both sides (legacy LLDP-style heuristic).
|
||||||
|
|
@ -184,10 +195,57 @@ def compare_rows(
|
||||||
before_dup_keys: list[tuple[str, ...]] = []
|
before_dup_keys: list[tuple[str, ...]] = []
|
||||||
diffs: list[dict[str, Any]] = []
|
diffs: list[dict[str, Any]] = []
|
||||||
added = removed = changed = unchanged = duplicate = 0
|
added = removed = changed = unchanged = duplicate = 0
|
||||||
|
unchanged_listed = 0
|
||||||
|
limit_n = None if unchanged_limit is None else max(0, int(unchanged_limit))
|
||||||
|
|
||||||
def _key_obj(row: dict[str, Any]) -> dict[str, Any]:
|
def _key_obj(row: dict[str, Any]) -> dict[str, Any]:
|
||||||
return {f: row.get(f, "") for f in key_fields}
|
return {f: row.get(f, "") for f in key_fields}
|
||||||
|
|
||||||
|
def _row_id(row: dict[str, Any] | None) -> str:
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
return ""
|
||||||
|
netx = row.get("_netx")
|
||||||
|
if isinstance(netx, dict):
|
||||||
|
return str(netx.get("row_id") or "")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def _emit_unchanged(orig: dict[str, Any], mapped: dict[str, Any], after_row: dict[str, Any]) -> None:
|
||||||
|
nonlocal unchanged_listed
|
||||||
|
if not include_unchanged:
|
||||||
|
return
|
||||||
|
if limit_n is not None and unchanged_listed >= limit_n:
|
||||||
|
return
|
||||||
|
unchanged_listed += 1
|
||||||
|
before_rid = _row_id(orig)
|
||||||
|
after_rid = _row_id(after_row)
|
||||||
|
if compact_unchanged:
|
||||||
|
diffs.append(
|
||||||
|
{
|
||||||
|
"kind": "unchanged",
|
||||||
|
"key": _key_obj(mapped),
|
||||||
|
"before": {},
|
||||||
|
"after": {},
|
||||||
|
"mapped_before": {},
|
||||||
|
"changes": {},
|
||||||
|
"compact": True,
|
||||||
|
"before_row_id": before_rid,
|
||||||
|
"after_row_id": after_rid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
diffs.append(
|
||||||
|
{
|
||||||
|
"kind": "unchanged",
|
||||||
|
"key": _key_obj(mapped),
|
||||||
|
"before": orig,
|
||||||
|
"after": after_row,
|
||||||
|
"mapped_before": mapped,
|
||||||
|
"changes": {},
|
||||||
|
"before_row_id": before_rid,
|
||||||
|
"after_row_id": after_rid,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
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:
|
if k in before_keys:
|
||||||
|
|
@ -248,16 +306,7 @@ def compare_rows(
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
unchanged += 1
|
unchanged += 1
|
||||||
diffs.append(
|
_emit_unchanged(orig, mapped, after)
|
||||||
{
|
|
||||||
"kind": "unchanged",
|
|
||||||
"key": _key_obj(mapped),
|
|
||||||
"before": orig,
|
|
||||||
"after": after,
|
|
||||||
"mapped_before": mapped,
|
|
||||||
"changes": {},
|
|
||||||
}
|
|
||||||
)
|
|
||||||
|
|
||||||
for k, after in after_index.items():
|
for k, after in after_index.items():
|
||||||
if k in before_keys:
|
if k in before_keys:
|
||||||
|
|
@ -319,6 +368,11 @@ def compare_rows(
|
||||||
"duplicate_keys_before": before_dup,
|
"duplicate_keys_before": before_dup,
|
||||||
"duplicate_keys_after": after_dup,
|
"duplicate_keys_after": after_dup,
|
||||||
"duplicate_key_list": _fmt_keys(before_dup_keys + after_dup_keys),
|
"duplicate_key_list": _fmt_keys(before_dup_keys + after_dup_keys),
|
||||||
|
"unchanged_listed": unchanged_listed,
|
||||||
|
"unchanged_truncated": bool(
|
||||||
|
include_unchanged and limit_n is not None and unchanged > unchanged_listed
|
||||||
|
),
|
||||||
|
"unchanged_compact": bool(compact_unchanged and unchanged_listed > 0),
|
||||||
},
|
},
|
||||||
"diffs": diffs,
|
"diffs": diffs,
|
||||||
"mapping_stats": stats,
|
"mapping_stats": stats,
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import io
|
||||||
import json
|
import json
|
||||||
import logging
|
import logging
|
||||||
import threading
|
import threading
|
||||||
|
import time
|
||||||
import zipfile
|
import zipfile
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
@ -181,7 +182,61 @@ def _compare_side(
|
||||||
}
|
}
|
||||||
|
|
||||||
_DIFF_CHUNK = 2000
|
_DIFF_CHUNK = 2000
|
||||||
|
_LOAD_YIELD_PER = 5000
|
||||||
_SEARCH_TEXT_MAX = 4000
|
_SEARCH_TEXT_MAX = 4000
|
||||||
|
# Success-row persist policy (see resolve_unchanged_policy)
|
||||||
|
_STORE_UNCHANGED_MODES = frozenset({"auto", "always", "never", "sample", "keys"})
|
||||||
|
_UNCHANGED_FULL_MAX = 20_000
|
||||||
|
_UNCHANGED_SAMPLE_MAX = 5_000
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_store_unchanged(raw: Any) -> str:
|
||||||
|
m = str(raw or "auto").strip().lower()
|
||||||
|
return m if m in _STORE_UNCHANGED_MODES else "auto"
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_unchanged_policy(
|
||||||
|
mode: str, *, before_n: int, after_n: int
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Decide whether / how many success rows to emit for one sheet.
|
||||||
|
|
||||||
|
- always: full before/after for every match (slow on million-row sheets)
|
||||||
|
- never: count only
|
||||||
|
- sample: key + row_id sample (cap) — cutover spot-check default for large sheets
|
||||||
|
- keys: all success as key + row_id (full browse; write still heavy)
|
||||||
|
- auto: full when sheet is small; otherwise sample compact
|
||||||
|
"""
|
||||||
|
m = normalize_store_unchanged(mode)
|
||||||
|
n = max(int(before_n or 0), int(after_n or 0))
|
||||||
|
if m == "never":
|
||||||
|
return {"mode": m, "include": False, "limit": None, "compact": False}
|
||||||
|
if m == "always":
|
||||||
|
return {"mode": m, "include": True, "limit": None, "compact": False}
|
||||||
|
if m == "keys":
|
||||||
|
return {"mode": m, "include": True, "limit": None, "compact": True}
|
||||||
|
if m == "sample":
|
||||||
|
return {
|
||||||
|
"mode": m,
|
||||||
|
"include": True,
|
||||||
|
"limit": _UNCHANGED_SAMPLE_MAX,
|
||||||
|
"compact": True,
|
||||||
|
}
|
||||||
|
# auto — cutover-oriented: large sheets sample, not full key dump
|
||||||
|
if n <= _UNCHANGED_FULL_MAX:
|
||||||
|
return {"mode": m, "include": True, "limit": None, "compact": False}
|
||||||
|
return {
|
||||||
|
"mode": m,
|
||||||
|
"include": True,
|
||||||
|
"limit": _UNCHANGED_SAMPLE_MAX,
|
||||||
|
"compact": True,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_netx(row: Any) -> dict[str, Any]:
|
||||||
|
"""Drop collector provenance before persisting compare payloads."""
|
||||||
|
if not isinstance(row, dict):
|
||||||
|
return {}
|
||||||
|
return {k: v for k, v in row.items() if k != "_netx"}
|
||||||
|
|
||||||
|
|
||||||
def _diff_search_text(d: dict[str, Any]) -> str:
|
def _diff_search_text(d: dict[str, Any]) -> str:
|
||||||
|
|
@ -215,30 +270,114 @@ def _persist_sheet_diffs(
|
||||||
run_id: str,
|
run_id: str,
|
||||||
metric_id: str,
|
metric_id: str,
|
||||||
diffs: list[dict[str, Any]],
|
diffs: list[dict[str, Any]],
|
||||||
) -> None:
|
seq_start: int = 0,
|
||||||
"""Bulk-insert diff rows; avoids embedding million-row arrays in summary_json."""
|
) -> int:
|
||||||
|
"""Bulk-insert diff rows already selected by the engine policy.
|
||||||
|
|
||||||
|
Compact success rows carry key + before/after_row_id; JSON sides stay empty
|
||||||
|
and are hydrated from metric tables on read.
|
||||||
|
"""
|
||||||
buf: list[dict[str, Any]] = []
|
buf: list[dict[str, Any]] = []
|
||||||
for i, d in enumerate(diffs):
|
seq = int(seq_start or 0)
|
||||||
|
written = 0
|
||||||
|
for d in diffs:
|
||||||
|
kind = str(d.get("kind") or "")
|
||||||
|
before = _strip_netx(d.get("before"))
|
||||||
|
after = _strip_netx(d.get("after"))
|
||||||
|
mapped = _strip_netx(d.get("mapped_before"))
|
||||||
|
payload = {
|
||||||
|
"kind": kind,
|
||||||
|
"key": dict(d.get("key") or {}),
|
||||||
|
"before": before,
|
||||||
|
"after": after,
|
||||||
|
"mapped_before": mapped,
|
||||||
|
"changes": dict(d.get("changes") or {}),
|
||||||
|
}
|
||||||
|
# Compact success: search_text = kind + key only (no fat sides)
|
||||||
|
search_src = (
|
||||||
|
{"kind": kind, "key": payload["key"]}
|
||||||
|
if kind == "unchanged" and bool(d.get("compact"))
|
||||||
|
else payload
|
||||||
|
)
|
||||||
buf.append(
|
buf.append(
|
||||||
{
|
{
|
||||||
"id": uuid4().hex,
|
"id": uuid4().hex,
|
||||||
"run_id": run_id,
|
"run_id": run_id,
|
||||||
"metric_id": metric_id,
|
"metric_id": metric_id,
|
||||||
"seq": i,
|
"seq": seq,
|
||||||
"kind": str(d.get("kind") or ""),
|
"kind": kind,
|
||||||
"key_json": dict(d.get("key") or {}),
|
"key_json": payload["key"],
|
||||||
"before_json": dict(d.get("before") or {}),
|
"before_json": before,
|
||||||
"after_json": dict(d.get("after") or {}),
|
"after_json": after,
|
||||||
"mapped_before_json": dict(d.get("mapped_before") or {}),
|
"mapped_before_json": mapped,
|
||||||
"changes_json": dict(d.get("changes") or {}),
|
"changes_json": payload["changes"],
|
||||||
"search_text": _diff_search_text(d),
|
"before_row_id": str(d.get("before_row_id") or "")[:64],
|
||||||
|
"after_row_id": str(d.get("after_row_id") or "")[:64],
|
||||||
|
"search_text": _diff_search_text(search_src),
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
seq += 1
|
||||||
|
written += 1
|
||||||
if len(buf) >= _DIFF_CHUNK:
|
if len(buf) >= _DIFF_CHUNK:
|
||||||
db.bulk_insert_mappings(BizCompareDiff, buf)
|
db.bulk_insert_mappings(BizCompareDiff, buf)
|
||||||
buf.clear()
|
buf.clear()
|
||||||
if buf:
|
if buf:
|
||||||
db.bulk_insert_mappings(BizCompareDiff, buf)
|
db.bulk_insert_mappings(BizCompareDiff, buf)
|
||||||
|
return written
|
||||||
|
|
||||||
|
|
||||||
|
def _metric_rows_by_ids(db: Session, ids: list[str]) -> dict[str, dict[str, Any]]:
|
||||||
|
"""Load BizStateMetricRow / LLDP rows by primary key for hydrate."""
|
||||||
|
clean = [str(i).strip() for i in ids if str(i or "").strip()]
|
||||||
|
if not clean:
|
||||||
|
return {}
|
||||||
|
from ..models import BizStateMetricRow
|
||||||
|
|
||||||
|
out: dict[str, dict[str, Any]] = {}
|
||||||
|
# Chunk IN lists for large pages
|
||||||
|
for i in range(0, len(clean), 500):
|
||||||
|
chunk = clean[i : i + 500]
|
||||||
|
for r in db.query(BizStateMetricRow).filter(BizStateMetricRow.id.in_(chunk)).all():
|
||||||
|
out[str(r.id)] = dict(r.data_json or {})
|
||||||
|
missing = [x for x in chunk if x not in out]
|
||||||
|
if missing:
|
||||||
|
for n in (
|
||||||
|
db.query(BizStateLldpNeighbor)
|
||||||
|
.filter(BizStateLldpNeighbor.id.in_(missing))
|
||||||
|
.all()
|
||||||
|
):
|
||||||
|
out[str(n.id)] = {
|
||||||
|
"local_if": n.local_if,
|
||||||
|
"remote_sys": n.remote_sys,
|
||||||
|
"remote_if": n.remote_if,
|
||||||
|
"remote_ip": n.remote_ip,
|
||||||
|
"protocol": n.protocol,
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
def _hydrate_diff_rows(db: Session, items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||||
|
"""Fill empty before/after from metric tables when row_ids are present."""
|
||||||
|
need: list[str] = []
|
||||||
|
for it in items:
|
||||||
|
if not it.get("before") and it.get("before_row_id"):
|
||||||
|
need.append(str(it["before_row_id"]))
|
||||||
|
if not it.get("after") and it.get("after_row_id"):
|
||||||
|
need.append(str(it["after_row_id"]))
|
||||||
|
if not need:
|
||||||
|
return items
|
||||||
|
by_id = _metric_rows_by_ids(db, need)
|
||||||
|
for it in items:
|
||||||
|
brid = str(it.get("before_row_id") or "")
|
||||||
|
arid = str(it.get("after_row_id") or "")
|
||||||
|
if not it.get("before") and brid and brid in by_id:
|
||||||
|
it["before"] = by_id[brid]
|
||||||
|
if not it.get("after") and arid and arid in by_id:
|
||||||
|
it["after"] = by_id[arid]
|
||||||
|
# Success compact: no mapped_before stored — UI falls back to before
|
||||||
|
if not it.get("mapped_before") and it.get("before"):
|
||||||
|
it["mapped_before"] = dict(it["before"])
|
||||||
|
return items
|
||||||
|
|
||||||
|
|
||||||
def _diff_row_out(r: BizCompareDiff) -> dict[str, Any]:
|
def _diff_row_out(r: BizCompareDiff) -> dict[str, Any]:
|
||||||
|
|
@ -249,6 +388,8 @@ def _diff_row_out(r: BizCompareDiff) -> dict[str, Any]:
|
||||||
"after": r.after_json or {},
|
"after": r.after_json or {},
|
||||||
"mapped_before": r.mapped_before_json or {},
|
"mapped_before": r.mapped_before_json or {},
|
||||||
"changes": r.changes_json or {},
|
"changes": r.changes_json or {},
|
||||||
|
"before_row_id": getattr(r, "before_row_id", "") or "",
|
||||||
|
"after_row_id": getattr(r, "after_row_id", "") or "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1207,47 +1348,54 @@ def _port_map_dict(db: Session, mapping_id: str) -> dict[str, str]:
|
||||||
|
|
||||||
|
|
||||||
def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dict[str, Any]]:
|
def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dict[str, Any]]:
|
||||||
|
"""Load metric rows with streaming fetch to avoid holding the full ORM set."""
|
||||||
if metric_id == "lldp_neighbor":
|
if metric_id == "lldp_neighbor":
|
||||||
rows = (
|
q = (
|
||||||
db.query(BizStateLldpNeighbor)
|
db.query(BizStateLldpNeighbor)
|
||||||
.filter(BizStateLldpNeighbor.batch_id == batch_id)
|
.filter(BizStateLldpNeighbor.batch_id == batch_id)
|
||||||
.all()
|
.execution_options(stream_results=True, yield_per=_LOAD_YIELD_PER)
|
||||||
)
|
)
|
||||||
return [
|
out: list[dict[str, Any]] = []
|
||||||
{
|
for n in q:
|
||||||
"local_if": n.local_if,
|
out.append(
|
||||||
"remote_sys": n.remote_sys,
|
{
|
||||||
"remote_if": n.remote_if,
|
"local_if": n.local_if,
|
||||||
"remote_ip": n.remote_ip,
|
"remote_sys": n.remote_sys,
|
||||||
"protocol": n.protocol,
|
"remote_if": n.remote_if,
|
||||||
"_netx": {
|
"remote_ip": n.remote_ip,
|
||||||
"batch_id": batch_id,
|
"protocol": n.protocol,
|
||||||
"batch_command_id": n.batch_command_id or "",
|
"_netx": {
|
||||||
"task_id": n.task_id or "",
|
"batch_id": batch_id,
|
||||||
"ne_id": n.ne_id or "",
|
"batch_command_id": n.batch_command_id or "",
|
||||||
"collected_at": n.collected_at.isoformat() + "Z" if n.collected_at else None,
|
"task_id": n.task_id or "",
|
||||||
"row_id": n.id,
|
"ne_id": n.ne_id or "",
|
||||||
},
|
"collected_at": n.collected_at.isoformat() + "Z"
|
||||||
}
|
if n.collected_at
|
||||||
for n in rows
|
else None,
|
||||||
]
|
"row_id": n.id,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
)
|
||||||
|
db.expunge(n)
|
||||||
|
return out
|
||||||
# Generic tabular metrics (ISIS / interface / ARP / ND6 / BGP …)
|
# Generic tabular metrics (ISIS / interface / ARP / ND6 / BGP …)
|
||||||
from ..models import BizStateMetricRow
|
from ..models import BizStateMetricRow
|
||||||
|
|
||||||
rows = (
|
q = (
|
||||||
db.query(BizStateMetricRow)
|
db.query(BizStateMetricRow)
|
||||||
.filter(
|
.filter(
|
||||||
BizStateMetricRow.batch_id == batch_id,
|
BizStateMetricRow.batch_id == batch_id,
|
||||||
BizStateMetricRow.metric_id == metric_id,
|
BizStateMetricRow.metric_id == metric_id,
|
||||||
)
|
)
|
||||||
.order_by(BizStateMetricRow.seq.asc(), BizStateMetricRow.id.asc())
|
.order_by(BizStateMetricRow.seq.asc(), BizStateMetricRow.id.asc())
|
||||||
.all()
|
.execution_options(stream_results=True, yield_per=_LOAD_YIELD_PER)
|
||||||
)
|
)
|
||||||
if rows:
|
out = []
|
||||||
|
for r in q:
|
||||||
# Raw rows only — filtering belongs to the compare sheet template
|
# Raw rows only — filtering belongs to the compare sheet template
|
||||||
# (``row_filters``), not metric-specific branches here.
|
# (``row_filters``), not metric-specific branches here.
|
||||||
# ``_netx`` is collector provenance (stripped before field compare).
|
# ``_netx`` is collector provenance (stripped before field compare).
|
||||||
return [
|
out.append(
|
||||||
{
|
{
|
||||||
**dict(r.data_json or {}),
|
**dict(r.data_json or {}),
|
||||||
"_netx": {
|
"_netx": {
|
||||||
|
|
@ -1255,12 +1403,16 @@ def _load_metric_rows(db: Session, *, batch_id: str, metric_id: str) -> list[dic
|
||||||
"batch_command_id": r.batch_command_id or "",
|
"batch_command_id": r.batch_command_id or "",
|
||||||
"task_id": r.task_id or "",
|
"task_id": r.task_id or "",
|
||||||
"ne_id": r.ne_id or "",
|
"ne_id": r.ne_id or "",
|
||||||
"collected_at": r.collected_at.isoformat() + "Z" if r.collected_at else None,
|
"collected_at": r.collected_at.isoformat() + "Z"
|
||||||
|
if r.collected_at
|
||||||
|
else None,
|
||||||
"row_id": r.id,
|
"row_id": r.id,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for r in rows
|
)
|
||||||
]
|
db.expunge(r)
|
||||||
|
if out:
|
||||||
|
return out
|
||||||
# Known metric with zero rows is OK; unknown metric still errors
|
# Known metric with zero rows is OK; unknown metric still errors
|
||||||
if metric_id in metric_field_map():
|
if metric_id in metric_field_map():
|
||||||
return []
|
return []
|
||||||
|
|
@ -1318,6 +1470,7 @@ def _job_out(j: BizCompareJob) -> dict[str, Any]:
|
||||||
"mode": j.mode,
|
"mode": j.mode,
|
||||||
"status": j.status,
|
"status": j.status,
|
||||||
"enabled_sheet_ids": _str_list(getattr(j, "enabled_sheet_ids", None)),
|
"enabled_sheet_ids": _str_list(getattr(j, "enabled_sheet_ids", None)),
|
||||||
|
"store_unchanged": normalize_store_unchanged(getattr(j, "store_unchanged", None)),
|
||||||
"note": j.note,
|
"note": j.note,
|
||||||
"updated_at": j.updated_at.isoformat() + "Z" if j.updated_at else None,
|
"updated_at": j.updated_at.isoformat() + "Z" if j.updated_at else None,
|
||||||
}
|
}
|
||||||
|
|
@ -1360,6 +1513,7 @@ def create_job(db: Session, body: dict[str, Any]) -> dict[str, Any]:
|
||||||
mode=str(body.get("mode") or "manual")[:16],
|
mode=str(body.get("mode") or "manual")[:16],
|
||||||
status="ready",
|
status="ready",
|
||||||
enabled_sheet_ids=enabled,
|
enabled_sheet_ids=enabled,
|
||||||
|
store_unchanged=normalize_store_unchanged(body.get("store_unchanged")),
|
||||||
note=str(body.get("note") or "")[:512],
|
note=str(body.get("note") or "")[:512],
|
||||||
created_at=_utcnow(),
|
created_at=_utcnow(),
|
||||||
updated_at=_utcnow(),
|
updated_at=_utcnow(),
|
||||||
|
|
@ -1391,6 +1545,8 @@ def update_job(db: Session, job_id: str, body: dict[str, Any]) -> dict[str, Any]
|
||||||
setattr(j, key, str(body.get(key) or ""))
|
setattr(j, key, str(body.get(key) or ""))
|
||||||
if "enabled_sheet_ids" in body:
|
if "enabled_sheet_ids" in body:
|
||||||
j.enabled_sheet_ids = _str_list(body.get("enabled_sheet_ids"))
|
j.enabled_sheet_ids = _str_list(body.get("enabled_sheet_ids"))
|
||||||
|
if "store_unchanged" in body:
|
||||||
|
j.store_unchanged = normalize_store_unchanged(body.get("store_unchanged"))
|
||||||
j.updated_at = _utcnow()
|
j.updated_at = _utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
return _job_out(j)
|
return _job_out(j)
|
||||||
|
|
@ -1452,6 +1608,7 @@ def _run_sheet(
|
||||||
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,
|
iface_normalize_rules: list[dict[str, str]] | None = None,
|
||||||
|
store_unchanged: str = "auto",
|
||||||
) -> 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 [])
|
||||||
|
|
@ -1476,6 +1633,9 @@ def _run_sheet(
|
||||||
after_raw = _load_metric_rows(db, batch_id=after_batch_id, metric_id=sheet["metric_id"])
|
after_raw = _load_metric_rows(db, batch_id=after_batch_id, metric_id=sheet["metric_id"])
|
||||||
before_rows = apply_row_filters(before_raw, row_filters)
|
before_rows = apply_row_filters(before_raw, row_filters)
|
||||||
after_rows = apply_row_filters(after_raw, row_filters)
|
after_rows = apply_row_filters(after_raw, row_filters)
|
||||||
|
policy = resolve_unchanged_policy(
|
||||||
|
store_unchanged, before_n=len(before_rows), after_n=len(after_rows)
|
||||||
|
)
|
||||||
result = compare_rows(
|
result = compare_rows(
|
||||||
before_rows=before_rows,
|
before_rows=before_rows,
|
||||||
after_rows=after_rows,
|
after_rows=after_rows,
|
||||||
|
|
@ -1486,11 +1646,15 @@ def _run_sheet(
|
||||||
field_rules=field_rules,
|
field_rules=field_rules,
|
||||||
iface_normalize_rules=iface_normalize_rules,
|
iface_normalize_rules=iface_normalize_rules,
|
||||||
ignore_port_changes=ignore_ports,
|
ignore_port_changes=ignore_ports,
|
||||||
|
include_unchanged=bool(policy["include"]),
|
||||||
|
unchanged_limit=policy.get("limit"),
|
||||||
|
compact_unchanged=bool(policy.get("compact")),
|
||||||
)
|
)
|
||||||
summary = dict(result["summary"])
|
summary = dict(result["summary"])
|
||||||
summary["before_raw_count"] = len(before_raw)
|
summary["before_raw_count"] = len(before_raw)
|
||||||
summary["after_raw_count"] = len(after_raw)
|
summary["after_raw_count"] = len(after_raw)
|
||||||
summary["row_filters"] = len(row_filters)
|
summary["row_filters"] = len(row_filters)
|
||||||
|
summary["unchanged_policy"] = policy
|
||||||
return {
|
return {
|
||||||
"sheet_id": sheet_key(sheet),
|
"sheet_id": sheet_key(sheet),
|
||||||
"title": sheet_title(sheet),
|
"title": sheet_title(sheet),
|
||||||
|
|
@ -1509,19 +1673,10 @@ def _run_sheet(
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> dict[str, Any]:
|
def _validate_compare_job(
|
||||||
lock = _job_compare_lock(job_id)
|
|
||||||
if not lock.acquire(blocking=False):
|
|
||||||
raise HTTPException(status_code=409, detail="compare_already_running")
|
|
||||||
try:
|
|
||||||
return _run_compare_unlocked(db, job_id, force_after_batch_id=force_after_batch_id)
|
|
||||||
finally:
|
|
||||||
lock.release()
|
|
||||||
|
|
||||||
|
|
||||||
def _run_compare_unlocked(
|
|
||||||
db: Session, job_id: str, *, force_after_batch_id: str = ""
|
db: Session, job_id: str, *, force_after_batch_id: str = ""
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
|
"""Resolve job/template/batches/sheets; raises HTTPException on bad input."""
|
||||||
j = db.get(BizCompareJob, job_id)
|
j = db.get(BizCompareJob, job_id)
|
||||||
if not j:
|
if not j:
|
||||||
raise HTTPException(status_code=404, detail="job_not_found")
|
raise HTTPException(status_code=404, detail="job_not_found")
|
||||||
|
|
@ -1541,10 +1696,136 @@ def _run_compare_unlocked(
|
||||||
sheets_cfg = _filter_enabled_sheets(sheets_cfg, getattr(j, "enabled_sheet_ids", None))
|
sheets_cfg = _filter_enabled_sheets(sheets_cfg, getattr(j, "enabled_sheet_ids", None))
|
||||||
if not sheets_cfg:
|
if not sheets_cfg:
|
||||||
raise HTTPException(status_code=400, detail="no_enabled_sheets")
|
raise HTTPException(status_code=400, detail="no_enabled_sheets")
|
||||||
|
return {
|
||||||
|
"job": j,
|
||||||
|
"template": tpl,
|
||||||
|
"before_batch_id": before_batch_id,
|
||||||
|
"after_batch_id": after_batch_id,
|
||||||
|
"sheets_cfg": sheets_cfg,
|
||||||
|
}
|
||||||
|
|
||||||
pmap = _port_map_dict(db, j.mapping_id)
|
|
||||||
|
def _create_running_run(
|
||||||
|
db: Session,
|
||||||
|
*,
|
||||||
|
job: BizCompareJob,
|
||||||
|
tpl: BizCompareTemplate,
|
||||||
|
before_batch_id: str,
|
||||||
|
after_batch_id: str,
|
||||||
|
sheets_cfg: list[dict[str, Any]],
|
||||||
|
) -> BizCompareRun:
|
||||||
|
first_metric = str(sheets_cfg[0].get("metric_id") or "")
|
||||||
|
run = BizCompareRun(
|
||||||
|
id=uuid4().hex,
|
||||||
|
job_id=job.id,
|
||||||
|
template_id=tpl.id,
|
||||||
|
mapping_id=job.mapping_id,
|
||||||
|
before_batch_id=before_batch_id,
|
||||||
|
after_batch_id=after_batch_id,
|
||||||
|
metric_id=first_metric,
|
||||||
|
status="running",
|
||||||
|
summary_json={
|
||||||
|
"progress": {
|
||||||
|
"phase": "queued",
|
||||||
|
"sheet_index": 0,
|
||||||
|
"sheet_total": len(sheets_cfg),
|
||||||
|
"sheet_id": "",
|
||||||
|
"sheet_title": "",
|
||||||
|
"elapsed_ms": 0,
|
||||||
|
},
|
||||||
|
"sheet_count": len(sheets_cfg),
|
||||||
|
"added": 0,
|
||||||
|
"removed": 0,
|
||||||
|
"changed": 0,
|
||||||
|
"unchanged": 0,
|
||||||
|
"duplicate": 0,
|
||||||
|
"before_count": 0,
|
||||||
|
"after_count": 0,
|
||||||
|
"sheets": [],
|
||||||
|
},
|
||||||
|
diffs_json=[],
|
||||||
|
mapping_stats_json={},
|
||||||
|
message="queued",
|
||||||
|
created_at=_utcnow(),
|
||||||
|
)
|
||||||
|
db.add(run)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(run)
|
||||||
|
return run
|
||||||
|
|
||||||
|
|
||||||
|
def _set_run_progress(
|
||||||
|
db: Session,
|
||||||
|
run: BizCompareRun,
|
||||||
|
*,
|
||||||
|
phase: str,
|
||||||
|
sheet_index: int,
|
||||||
|
sheet_total: int,
|
||||||
|
sheet: dict[str, Any] | None,
|
||||||
|
started_mono: float,
|
||||||
|
extra: dict[str, Any] | None = None,
|
||||||
|
) -> None:
|
||||||
|
elapsed_ms = int((time.monotonic() - started_mono) * 1000)
|
||||||
|
prev = dict(run.summary_json or {})
|
||||||
|
progress = {
|
||||||
|
"phase": phase,
|
||||||
|
"sheet_index": sheet_index,
|
||||||
|
"sheet_total": sheet_total,
|
||||||
|
"sheet_id": sheet_key(sheet) if sheet else "",
|
||||||
|
"sheet_title": sheet_title(sheet) if sheet else "",
|
||||||
|
"elapsed_ms": elapsed_ms,
|
||||||
|
}
|
||||||
|
if extra:
|
||||||
|
progress.update(extra)
|
||||||
|
prev["progress"] = progress
|
||||||
|
run.summary_json = prev
|
||||||
|
run.status = "running"
|
||||||
|
title = progress["sheet_title"] or progress["sheet_id"] or ""
|
||||||
|
run.message = (
|
||||||
|
f"{phase} {sheet_index}/{sheet_total}"
|
||||||
|
+ (f" · {title}" if title else "")
|
||||||
|
+ f" · {elapsed_ms // 1000}s"
|
||||||
|
)[:1024]
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
|
||||||
|
def _execute_compare_into_run(db: Session, run_id: str) -> dict[str, Any]:
|
||||||
|
"""Run compare into an existing ``running`` BizCompareRun; persist sheet-by-sheet."""
|
||||||
|
run = db.get(BizCompareRun, run_id)
|
||||||
|
if not run:
|
||||||
|
raise HTTPException(status_code=404, detail="run_not_found")
|
||||||
|
j = db.get(BizCompareJob, run.job_id)
|
||||||
|
if not j:
|
||||||
|
run.status = "failed"
|
||||||
|
run.message = "job_not_found"
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=404, detail="job_not_found")
|
||||||
|
tpl = db.get(BizCompareTemplate, run.template_id)
|
||||||
|
if not tpl:
|
||||||
|
run.status = "failed"
|
||||||
|
run.message = "template_not_found"
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=404, detail="template_not_found")
|
||||||
|
|
||||||
|
before_batch_id = str(run.before_batch_id or "")
|
||||||
|
after_batch_id = str(run.after_batch_id or "")
|
||||||
|
sheets_cfg = _filter_enabled_sheets(
|
||||||
|
template_metrics(tpl), getattr(j, "enabled_sheet_ids", None)
|
||||||
|
)
|
||||||
|
if not sheets_cfg:
|
||||||
|
run.status = "failed"
|
||||||
|
run.message = "no_enabled_sheets"
|
||||||
|
db.commit()
|
||||||
|
raise HTTPException(status_code=400, detail="no_enabled_sheets")
|
||||||
|
|
||||||
|
started_mono = time.monotonic()
|
||||||
|
store_mode = normalize_store_unchanged(getattr(j, "store_unchanged", None))
|
||||||
|
pmap = _port_map_dict(db, run.mapping_id)
|
||||||
norm_rules = template_iface_normalize(tpl)
|
norm_rules = template_iface_normalize(tpl)
|
||||||
sheet_results: list[dict[str, Any]] = []
|
sheet_metas: list[dict[str, Any]] = []
|
||||||
|
unchanged_listed_total = 0
|
||||||
|
unchanged_truncated_any = False
|
||||||
|
unchanged_compact_any = False
|
||||||
agg = {
|
agg = {
|
||||||
"before_count": 0,
|
"before_count": 0,
|
||||||
"after_count": 0,
|
"after_count": 0,
|
||||||
|
|
@ -1555,87 +1836,250 @@ def _run_compare_unlocked(
|
||||||
"duplicate": 0,
|
"duplicate": 0,
|
||||||
}
|
}
|
||||||
mapping_by_metric: dict[str, Any] = {}
|
mapping_by_metric: dict[str, Any] = {}
|
||||||
for sheet in sheets_cfg:
|
|
||||||
one = _run_sheet(
|
|
||||||
db,
|
|
||||||
sheet=sheet,
|
|
||||||
before_batch_id=before_batch_id,
|
|
||||||
after_batch_id=after_batch_id,
|
|
||||||
port_map=pmap,
|
|
||||||
iface_normalize_rules=norm_rules,
|
|
||||||
)
|
|
||||||
sheet_results.append(one)
|
|
||||||
s = one["summary"]
|
|
||||||
for k in agg:
|
|
||||||
agg[k] += int(s.get(k) or 0)
|
|
||||||
mapping_by_metric[sheet_key(one)] = one["mapping_stats"]
|
|
||||||
|
|
||||||
first = sheet_results[0]
|
|
||||||
field_counts: dict[str, int] = {}
|
field_counts: dict[str, int] = {}
|
||||||
for s in sheet_results:
|
total = len(sheets_cfg)
|
||||||
for d in list(s.get("diffs") or []):
|
|
||||||
if str(d.get("kind") or "") != "changed":
|
|
||||||
continue
|
|
||||||
for fname in d.get("changes") or {}:
|
|
||||||
field_counts[str(fname)] = field_counts.get(str(fname), 0) + 1
|
|
||||||
top_fields = sorted(
|
|
||||||
[{"field": k, "count": v} for k, v in field_counts.items()],
|
|
||||||
key=lambda x: (-int(x["count"]), str(x["field"])),
|
|
||||||
)[:8]
|
|
||||||
|
|
||||||
run_id = uuid4().hex
|
try:
|
||||||
# Persist counts/meta only — diffs go to biz_compare_diff rows
|
for idx, sheet in enumerate(sheets_cfg, start=1):
|
||||||
summary_payload = {
|
_set_run_progress(
|
||||||
**agg,
|
db,
|
||||||
"sheet_count": len(sheet_results),
|
run,
|
||||||
"top_changed_fields": top_fields,
|
phase="loading",
|
||||||
"sheets": [
|
sheet_index=idx,
|
||||||
{
|
sheet_total=total,
|
||||||
"sheet_id": s.get("sheet_id") or s["metric_id"],
|
sheet=sheet,
|
||||||
"title": s.get("title") or s.get("sheet_id") or s["metric_id"],
|
started_mono=started_mono,
|
||||||
"metric_id": s["metric_id"],
|
)
|
||||||
"key_fields": s["key_fields"],
|
one = _run_sheet(
|
||||||
"iface_fields": s["iface_fields"],
|
db,
|
||||||
"compare_fields": s["compare_fields"],
|
sheet=sheet,
|
||||||
"display_fields": s.get("display_fields") or [],
|
before_batch_id=before_batch_id,
|
||||||
"field_rules": s.get("field_rules") or [],
|
after_batch_id=after_batch_id,
|
||||||
"ignore_port_changes": s.get("ignore_port_changes"),
|
port_map=pmap,
|
||||||
"mode": s["mode"],
|
iface_normalize_rules=norm_rules,
|
||||||
"summary": s["summary"],
|
store_unchanged=store_mode,
|
||||||
}
|
)
|
||||||
for s in sheet_results
|
s = one["summary"]
|
||||||
],
|
listed = int(s.get("unchanged_listed") or 0)
|
||||||
}
|
unchanged_listed_total += listed
|
||||||
|
if s.get("unchanged_truncated"):
|
||||||
|
unchanged_truncated_any = True
|
||||||
|
if s.get("unchanged_compact"):
|
||||||
|
unchanged_compact_any = True
|
||||||
|
_set_run_progress(
|
||||||
|
db,
|
||||||
|
run,
|
||||||
|
phase="persisting",
|
||||||
|
sheet_index=idx,
|
||||||
|
sheet_total=total,
|
||||||
|
sheet=sheet,
|
||||||
|
started_mono=started_mono,
|
||||||
|
extra={
|
||||||
|
"before_count": int(s.get("before_count") or 0),
|
||||||
|
"after_count": int(s.get("after_count") or 0),
|
||||||
|
"diff_rows": int(s.get("added") or 0)
|
||||||
|
+ int(s.get("removed") or 0)
|
||||||
|
+ int(s.get("changed") or 0)
|
||||||
|
+ int(s.get("duplicate") or 0)
|
||||||
|
+ listed,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
diffs = list(one.get("diffs") or [])
|
||||||
|
for d in diffs:
|
||||||
|
if str(d.get("kind") or "") != "changed":
|
||||||
|
continue
|
||||||
|
for fname in d.get("changes") or {}:
|
||||||
|
field_counts[str(fname)] = field_counts.get(str(fname), 0) + 1
|
||||||
|
# Cutover-first: persist fails so the UI can open the fail tab ASAP,
|
||||||
|
# then write success slim keys (sample / keys mode).
|
||||||
|
fail_diffs = [d for d in diffs if str(d.get("kind") or "") != "unchanged"]
|
||||||
|
ok_diffs = [d for d in diffs if str(d.get("kind") or "") == "unchanged"]
|
||||||
|
mid = sheet_key(one)
|
||||||
|
_set_run_progress(
|
||||||
|
db,
|
||||||
|
run,
|
||||||
|
phase="persisting_fail",
|
||||||
|
sheet_index=idx,
|
||||||
|
sheet_total=total,
|
||||||
|
sheet=sheet,
|
||||||
|
started_mono=started_mono,
|
||||||
|
extra={"fail_rows": len(fail_diffs)},
|
||||||
|
)
|
||||||
|
n_fail = _persist_sheet_diffs(
|
||||||
|
db, run_id=run.id, metric_id=mid, diffs=fail_diffs, seq_start=0
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
if ok_diffs:
|
||||||
|
_set_run_progress(
|
||||||
|
db,
|
||||||
|
run,
|
||||||
|
phase="persisting_ok",
|
||||||
|
sheet_index=idx,
|
||||||
|
sheet_total=total,
|
||||||
|
sheet=sheet,
|
||||||
|
started_mono=started_mono,
|
||||||
|
extra={"ok_rows": len(ok_diffs)},
|
||||||
|
)
|
||||||
|
_persist_sheet_diffs(
|
||||||
|
db,
|
||||||
|
run_id=run.id,
|
||||||
|
metric_id=mid,
|
||||||
|
diffs=ok_diffs,
|
||||||
|
seq_start=n_fail,
|
||||||
|
)
|
||||||
|
for k in agg:
|
||||||
|
agg[k] += int(s.get(k) or 0)
|
||||||
|
mapping_by_metric[mid] = one["mapping_stats"]
|
||||||
|
sheet_metas.append(
|
||||||
|
{
|
||||||
|
"sheet_id": one.get("sheet_id") or one["metric_id"],
|
||||||
|
"title": one.get("title") or one.get("sheet_id") or one["metric_id"],
|
||||||
|
"metric_id": one["metric_id"],
|
||||||
|
"key_fields": one["key_fields"],
|
||||||
|
"iface_fields": one["iface_fields"],
|
||||||
|
"compare_fields": one["compare_fields"],
|
||||||
|
"display_fields": one.get("display_fields") or [],
|
||||||
|
"field_rules": one.get("field_rules") or [],
|
||||||
|
"ignore_port_changes": one.get("ignore_port_changes"),
|
||||||
|
"mode": one["mode"],
|
||||||
|
"summary": one["summary"],
|
||||||
|
}
|
||||||
|
)
|
||||||
|
# Drop heavy diffs before next sheet
|
||||||
|
one.clear()
|
||||||
|
diffs.clear()
|
||||||
|
fail_diffs.clear()
|
||||||
|
ok_diffs.clear()
|
||||||
|
db.commit()
|
||||||
|
|
||||||
run = BizCompareRun(
|
duration_ms = int((time.monotonic() - started_mono) * 1000)
|
||||||
id=run_id,
|
top_fields = sorted(
|
||||||
job_id=j.id,
|
[{"field": k, "count": v} for k, v in field_counts.items()],
|
||||||
template_id=tpl.id,
|
key=lambda x: (-int(x["count"]), str(x["field"])),
|
||||||
mapping_id=j.mapping_id,
|
)[:8]
|
||||||
before_batch_id=before_batch_id,
|
summary_payload = {
|
||||||
after_batch_id=after_batch_id,
|
**agg,
|
||||||
metric_id=first["metric_id"],
|
"sheet_count": len(sheet_metas),
|
||||||
status="success",
|
"top_changed_fields": top_fields,
|
||||||
summary_json=summary_payload,
|
"duration_ms": duration_ms,
|
||||||
diffs_json=[],
|
"store_unchanged": store_mode,
|
||||||
mapping_stats_json=mapping_by_metric,
|
"unchanged_stored": unchanged_listed_total > 0,
|
||||||
message="",
|
"unchanged_listed": unchanged_listed_total,
|
||||||
created_at=_utcnow(),
|
"unchanged_truncated": unchanged_truncated_any,
|
||||||
)
|
"unchanged_compact": unchanged_compact_any,
|
||||||
db.add(run)
|
"progress": {
|
||||||
db.flush()
|
"phase": "done",
|
||||||
for s in sheet_results:
|
"sheet_index": total,
|
||||||
_persist_sheet_diffs(
|
"sheet_total": total,
|
||||||
|
"sheet_id": "",
|
||||||
|
"sheet_title": "",
|
||||||
|
"elapsed_ms": duration_ms,
|
||||||
|
},
|
||||||
|
"sheets": sheet_metas,
|
||||||
|
}
|
||||||
|
run.status = "success"
|
||||||
|
run.summary_json = summary_payload
|
||||||
|
run.mapping_stats_json = mapping_by_metric
|
||||||
|
run.metric_id = str(sheet_metas[0]["metric_id"]) if sheet_metas else run.metric_id
|
||||||
|
run.message = f"done · {duration_ms // 1000}s"
|
||||||
|
run.diffs_json = []
|
||||||
|
j.updated_at = _utcnow()
|
||||||
|
if j.mode == "manual":
|
||||||
|
j.after_batch_id = after_batch_id
|
||||||
|
db.commit()
|
||||||
|
return get_run(db, run.id)
|
||||||
|
except HTTPException as exc:
|
||||||
|
run.status = "failed"
|
||||||
|
run.message = str(getattr(exc, "detail", "") or exc)[:1024]
|
||||||
|
prev = dict(run.summary_json or {})
|
||||||
|
prog = dict(prev.get("progress") or {})
|
||||||
|
prog["phase"] = "failed"
|
||||||
|
prog["elapsed_ms"] = int((time.monotonic() - started_mono) * 1000)
|
||||||
|
prev["progress"] = prog
|
||||||
|
prev["duration_ms"] = prog["elapsed_ms"]
|
||||||
|
run.summary_json = prev
|
||||||
|
db.commit()
|
||||||
|
raise
|
||||||
|
except Exception as exc:
|
||||||
|
_log.exception("compare run failed run=%s job=%s", run_id, j.id)
|
||||||
|
run.status = "failed"
|
||||||
|
run.message = str(exc)[:1024]
|
||||||
|
prev = dict(run.summary_json or {})
|
||||||
|
prog = dict(prev.get("progress") or {})
|
||||||
|
prog["phase"] = "failed"
|
||||||
|
prog["elapsed_ms"] = int((time.monotonic() - started_mono) * 1000)
|
||||||
|
prev["progress"] = prog
|
||||||
|
prev["duration_ms"] = prog["elapsed_ms"]
|
||||||
|
run.summary_json = prev
|
||||||
|
db.commit()
|
||||||
|
raise
|
||||||
|
|
||||||
|
|
||||||
|
def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") -> dict[str, Any]:
|
||||||
|
"""Synchronous compare (auto-compare / tests). Blocks the caller until done."""
|
||||||
|
lock = _job_compare_lock(job_id)
|
||||||
|
if not lock.acquire(blocking=False):
|
||||||
|
raise HTTPException(status_code=409, detail="compare_already_running")
|
||||||
|
try:
|
||||||
|
ctx = _validate_compare_job(db, job_id, force_after_batch_id=force_after_batch_id)
|
||||||
|
run = _create_running_run(
|
||||||
db,
|
db,
|
||||||
run_id=run_id,
|
job=ctx["job"],
|
||||||
metric_id=sheet_key(s),
|
tpl=ctx["template"],
|
||||||
diffs=list(s.get("diffs") or []),
|
before_batch_id=ctx["before_batch_id"],
|
||||||
|
after_batch_id=ctx["after_batch_id"],
|
||||||
|
sheets_cfg=ctx["sheets_cfg"],
|
||||||
)
|
)
|
||||||
j.updated_at = _utcnow()
|
return _execute_compare_into_run(db, run.id)
|
||||||
if j.mode == "manual":
|
finally:
|
||||||
j.after_batch_id = after_batch_id
|
lock.release()
|
||||||
db.commit()
|
|
||||||
return get_run(db, run.id)
|
|
||||||
|
def enqueue_compare(
|
||||||
|
db: Session, job_id: str, *, force_after_batch_id: str = ""
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Create a ``running`` run and execute compare on a daemon thread.
|
||||||
|
|
||||||
|
Returns immediately so the HTTP worker / UI stay responsive. Poll
|
||||||
|
``GET /compare/runs/{id}`` for progress (``summary.progress``).
|
||||||
|
"""
|
||||||
|
lock = _job_compare_lock(job_id)
|
||||||
|
if not lock.acquire(blocking=False):
|
||||||
|
raise HTTPException(status_code=409, detail="compare_already_running")
|
||||||
|
run_id = ""
|
||||||
|
try:
|
||||||
|
ctx = _validate_compare_job(db, job_id, force_after_batch_id=force_after_batch_id)
|
||||||
|
run = _create_running_run(
|
||||||
|
db,
|
||||||
|
job=ctx["job"],
|
||||||
|
tpl=ctx["template"],
|
||||||
|
before_batch_id=ctx["before_batch_id"],
|
||||||
|
after_batch_id=ctx["after_batch_id"],
|
||||||
|
sheets_cfg=ctx["sheets_cfg"],
|
||||||
|
)
|
||||||
|
run_id = run.id
|
||||||
|
except Exception:
|
||||||
|
lock.release()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def _bg() -> None:
|
||||||
|
from ..db import SessionLocal
|
||||||
|
|
||||||
|
s = SessionLocal()
|
||||||
|
try:
|
||||||
|
_execute_compare_into_run(s, run_id)
|
||||||
|
except Exception:
|
||||||
|
_log.exception("bg compare failed job=%s run=%s", job_id, run_id)
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
lock.release()
|
||||||
|
|
||||||
|
threading.Thread(
|
||||||
|
target=_bg,
|
||||||
|
name=f"biz-cmp-{run_id[:8]}",
|
||||||
|
daemon=True,
|
||||||
|
).start()
|
||||||
|
return get_run(db, run_id)
|
||||||
|
|
||||||
|
|
||||||
def _csv_cell(v: Any) -> str:
|
def _csv_cell(v: Any) -> str:
|
||||||
|
|
@ -1775,6 +2219,13 @@ def _enrich_summary(summary: dict[str, Any], sheets: list[dict[str, Any]]) -> di
|
||||||
"ok": fail_count == 0,
|
"ok": fail_count == 0,
|
||||||
"sheet_cards": sheet_cards,
|
"sheet_cards": sheet_cards,
|
||||||
"top_changed_fields": top_fields,
|
"top_changed_fields": top_fields,
|
||||||
|
"duration_ms": int(summary.get("duration_ms") or 0),
|
||||||
|
"store_unchanged": str(summary.get("store_unchanged") or ""),
|
||||||
|
"unchanged_stored": bool(summary.get("unchanged_stored", True)),
|
||||||
|
"unchanged_listed": int(summary.get("unchanged_listed") or 0),
|
||||||
|
"unchanged_truncated": bool(summary.get("unchanged_truncated")),
|
||||||
|
"unchanged_compact": bool(summary.get("unchanged_compact")),
|
||||||
|
"progress": dict(summary.get("progress") or {}),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -1893,12 +2344,13 @@ def list_run_diffs(
|
||||||
.limit(size_n)
|
.limit(size_n)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
items = _hydrate_diff_rows(db, [_diff_row_out(x) for x in rows])
|
||||||
return {
|
return {
|
||||||
"total": total,
|
"total": total,
|
||||||
"page": page_n,
|
"page": page_n,
|
||||||
"page_size": size_n,
|
"page_size": size_n,
|
||||||
"metric_id": mid,
|
"metric_id": mid,
|
||||||
"items": [_diff_row_out(x) for x in rows],
|
"items": items,
|
||||||
}
|
}
|
||||||
|
|
||||||
# Legacy: diffs embedded in summary_json / diffs_json
|
# Legacy: diffs embedded in summary_json / diffs_json
|
||||||
|
|
@ -1922,7 +2374,10 @@ def list_run_diffs(
|
||||||
|
|
||||||
|
|
||||||
def _iter_sheet_diffs(db: Session, run_id: str, metric_id: str) -> list[dict[str, Any]]:
|
def _iter_sheet_diffs(db: Session, run_id: str, metric_id: str) -> list[dict[str, Any]]:
|
||||||
"""Load all diffs for one sheet (export). Prefer row table; fall back to inline."""
|
"""Load all diffs for one sheet (export). Prefer row table; fall back to inline.
|
||||||
|
|
||||||
|
Compact success rows are hydrated in chunks from metric tables.
|
||||||
|
"""
|
||||||
if _run_has_diff_rows(db, run_id):
|
if _run_has_diff_rows(db, run_id):
|
||||||
out: list[dict[str, Any]] = []
|
out: list[dict[str, Any]] = []
|
||||||
offset = 0
|
offset = 0
|
||||||
|
|
@ -1937,7 +2392,8 @@ def _iter_sheet_diffs(db: Session, run_id: str, metric_id: str) -> list[dict[str
|
||||||
)
|
)
|
||||||
if not rows:
|
if not rows:
|
||||||
break
|
break
|
||||||
out.extend(_diff_row_out(x) for x in rows)
|
chunk = _hydrate_diff_rows(db, [_diff_row_out(x) for x in rows])
|
||||||
|
out.extend(chunk)
|
||||||
offset += len(rows)
|
offset += len(rows)
|
||||||
if len(rows) < _DIFF_CHUNK:
|
if len(rows) < _DIFF_CHUNK:
|
||||||
break
|
break
|
||||||
|
|
@ -2025,22 +2481,34 @@ def list_runs(db: Session, job_id: str, *, limit: int = 20) -> list[dict[str, An
|
||||||
job = db.get(BizCompareJob, job_id)
|
job = db.get(BizCompareJob, job_id)
|
||||||
before_tid = str(job.before_task_id or "") if job else ""
|
before_tid = str(job.before_task_id or "") if job else ""
|
||||||
after_tid = str(job.after_task_id or "") if job else ""
|
after_tid = str(job.after_task_id or "") if job else ""
|
||||||
return [
|
out: list[dict[str, Any]] = []
|
||||||
{
|
for r in rows:
|
||||||
"id": r.id,
|
summary = dict(r.summary_json or {})
|
||||||
"before_batch_id": r.before_batch_id,
|
out.append(
|
||||||
"after_batch_id": r.after_batch_id,
|
{
|
||||||
"before": _compare_side(db, r.before_batch_id, fallback_task_id=before_tid),
|
"id": r.id,
|
||||||
"after": _compare_side(db, r.after_batch_id, fallback_task_id=after_tid),
|
"before_batch_id": r.before_batch_id,
|
||||||
"status": r.status,
|
"after_batch_id": r.after_batch_id,
|
||||||
"summary": {
|
"before": _compare_side(db, r.before_batch_id, fallback_task_id=before_tid),
|
||||||
k: (r.summary_json or {}).get(k, 0)
|
"after": _compare_side(db, r.after_batch_id, fallback_task_id=after_tid),
|
||||||
for k in ("added", "removed", "changed", "unchanged", "sheet_count")
|
"status": r.status,
|
||||||
},
|
"message": r.message or "",
|
||||||
"created_at": r.created_at.isoformat() + "Z" if r.created_at else None,
|
"summary": {
|
||||||
}
|
k: summary.get(k, 0)
|
||||||
for r in rows
|
for k in (
|
||||||
]
|
"added",
|
||||||
|
"removed",
|
||||||
|
"changed",
|
||||||
|
"unchanged",
|
||||||
|
"sheet_count",
|
||||||
|
"duration_ms",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
"progress": dict(summary.get("progress") or {}),
|
||||||
|
"created_at": r.created_at.isoformat() + "Z" if r.created_at else None,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
def try_auto_compare_for_task(db: Session, task_id: str, batch_id: str) -> int:
|
def try_auto_compare_for_task(db: Session, task_id: str, batch_id: str) -> int:
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ def apply_biz_state_schema(conn: Connection) -> None:
|
||||||
"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)",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_state_lldp_neighbor_batch_id ON biz_state_lldp_neighbor (batch_id)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_state_lldp_neighbor_batch_id ON biz_state_lldp_neighbor (batch_id)",
|
||||||
"ALTER TABLE biz_compare_job ADD COLUMN IF NOT EXISTS enabled_sheet_ids JSON DEFAULT '[]'",
|
"ALTER TABLE biz_compare_job ADD COLUMN IF NOT EXISTS enabled_sheet_ids JSON DEFAULT '[]'",
|
||||||
|
"ALTER TABLE biz_compare_job ADD COLUMN IF NOT EXISTS store_unchanged VARCHAR(16) DEFAULT 'auto'",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_compare_job_status ON biz_compare_job (status)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_compare_job_status ON biz_compare_job (status)",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_compare_run_job_id ON biz_compare_run (job_id)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_compare_run_job_id ON biz_compare_run (job_id)",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_state_vrf_route_batch_id ON biz_state_vrf_route_summary (batch_id)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_state_vrf_route_batch_id ON biz_state_vrf_route_summary (batch_id)",
|
||||||
|
|
@ -31,6 +32,8 @@ def apply_biz_state_schema(conn: Connection) -> None:
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_id ON biz_compare_diff (run_id)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_id ON biz_compare_diff (run_id)",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_metric_kind ON biz_compare_diff (run_id, metric_id, kind)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_metric_kind ON biz_compare_diff (run_id, metric_id, kind)",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_metric_seq ON biz_compare_diff (run_id, metric_id, seq)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_compare_diff_run_metric_seq ON biz_compare_diff (run_id, metric_id, seq)",
|
||||||
|
"ALTER TABLE biz_compare_diff ADD COLUMN IF NOT EXISTS before_row_id VARCHAR(64) DEFAULT ''",
|
||||||
|
"ALTER TABLE biz_compare_diff ADD COLUMN IF NOT EXISTS after_row_id VARCHAR(64) DEFAULT ''",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_migration_project_status ON biz_migration_project (status)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_migration_project_status ON biz_migration_project (status)",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_migration_batch_project_id ON biz_migration_batch (project_id)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_migration_batch_project_id ON biz_migration_batch (project_id)",
|
||||||
"CREATE INDEX IF NOT EXISTS ix_biz_migration_run_batch_id ON biz_migration_run (batch_id)",
|
"CREATE INDEX IF NOT EXISTS ix_biz_migration_run_batch_id ON biz_migration_run (batch_id)",
|
||||||
|
|
|
||||||
|
|
@ -633,6 +633,8 @@ class CompareJobIn(BaseModel):
|
||||||
mode: str = "manual"
|
mode: str = "manual"
|
||||||
# Empty = all template sheets; non-empty = only these sheet_id values
|
# Empty = all template sheets; non-empty = only these sheet_id values
|
||||||
enabled_sheet_ids: list[str] = Field(default_factory=list)
|
enabled_sheet_ids: list[str] = Field(default_factory=list)
|
||||||
|
# auto|always|never|sample|keys — how to persist matching (success) rows
|
||||||
|
store_unchanged: str = "auto"
|
||||||
note: str = ""
|
note: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -723,8 +725,15 @@ def api_delete_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
@router.post("/compare/jobs/{job_id}/run")
|
@router.post("/compare/jobs/{job_id}/run")
|
||||||
def api_run_job(job_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
def api_run_job(
|
||||||
return cmp_svc.run_compare(db, job_id)
|
job_id: str,
|
||||||
|
sync: bool = Query(False, description="If true, block until compare finishes (tests/debug)"),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
) -> dict[str, Any]:
|
||||||
|
"""Start a compare run. Default is async (returns status=running immediately)."""
|
||||||
|
if sync:
|
||||||
|
return cmp_svc.run_compare(db, job_id)
|
||||||
|
return cmp_svc.enqueue_compare(db, job_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/compare/jobs/{job_id}/runs")
|
@router.get("/compare/jobs/{job_id}/runs")
|
||||||
|
|
|
||||||
|
|
@ -303,6 +303,8 @@ class BizCompareJob(Base):
|
||||||
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|ready|auto
|
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|ready|auto
|
||||||
# Empty = all template sheets; non-empty = only these sheet_id values
|
# Empty = all template sheets; non-empty = only these sheet_id values
|
||||||
enabled_sheet_ids: Mapped[list] = mapped_column(_JsonType, default=list)
|
enabled_sheet_ids: Mapped[list] = mapped_column(_JsonType, default=list)
|
||||||
|
# auto|always|never|sample — how to persist matching (success) rows
|
||||||
|
store_unchanged: Mapped[str] = mapped_column(String(16), default="auto")
|
||||||
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)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||||
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive)
|
||||||
|
|
@ -347,4 +349,7 @@ class BizCompareDiff(Base):
|
||||||
after_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
after_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||||
mapped_before_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
mapped_before_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||||
changes_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
changes_json: Mapped[dict] = mapped_column(_JsonType, default=dict)
|
||||||
|
# Pointers into BizStateMetricRow / LLDP tables for compact success hydrate
|
||||||
|
before_row_id: Mapped[str] = mapped_column(String(64), default="")
|
||||||
|
after_row_id: Mapped[str] = mapped_column(String(64), default="")
|
||||||
search_text: Mapped[str] = mapped_column(Text, default="")
|
search_text: Mapped[str] = mapped_column(Text, default="")
|
||||||
|
|
|
||||||
|
|
@ -96,7 +96,18 @@ class CompareEngineTests(unittest.TestCase):
|
||||||
self.assertEqual(s["removed"], 1)
|
self.assertEqual(s["removed"], 1)
|
||||||
self.assertEqual(s["added"], 1)
|
self.assertEqual(s["added"], 1)
|
||||||
kinds = {d["kind"] for d in out["diffs"]}
|
kinds = {d["kind"] for d in out["diffs"]}
|
||||||
self.assertIn("unchanged", kinds)
|
# Default: unchanged counted but not listed (million-row safe)
|
||||||
|
self.assertNotIn("unchanged", kinds)
|
||||||
|
out_full = compare_rows(
|
||||||
|
before_rows=before,
|
||||||
|
after_rows=after,
|
||||||
|
key_fields=["local_if", "remote_sys", "remote_if"],
|
||||||
|
iface_fields=["local_if"],
|
||||||
|
compare_fields=[],
|
||||||
|
port_map={},
|
||||||
|
include_unchanged=True,
|
||||||
|
)
|
||||||
|
self.assertIn("unchanged", {d["kind"] for d in out_full["diffs"]})
|
||||||
|
|
||||||
def test_empty_port_map_ignores_iface_in_key(self) -> None:
|
def test_empty_port_map_ignores_iface_in_key(self) -> None:
|
||||||
"""No port map → ignore local_if when matching (same neighbor, renamed port)."""
|
"""No port map → ignore local_if when matching (same neighbor, renamed port)."""
|
||||||
|
|
@ -149,10 +160,49 @@ class CompareEngineTests(unittest.TestCase):
|
||||||
iface_fields=["local_if"],
|
iface_fields=["local_if"],
|
||||||
compare_fields=["remote_ip"],
|
compare_fields=["remote_ip"],
|
||||||
port_map={},
|
port_map={},
|
||||||
|
include_unchanged=True,
|
||||||
)
|
)
|
||||||
self.assertEqual(out["summary"]["unchanged"], 1)
|
self.assertEqual(out["summary"]["unchanged"], 1)
|
||||||
self.assertEqual(len(out["diffs"]), 1)
|
self.assertEqual(len(out["diffs"]), 1)
|
||||||
self.assertEqual(out["diffs"][0]["kind"], "unchanged")
|
self.assertEqual(out["diffs"][0]["kind"], "unchanged")
|
||||||
|
slim = 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={},
|
||||||
|
)
|
||||||
|
self.assertEqual(slim["summary"]["unchanged"], 1)
|
||||||
|
self.assertEqual(slim["diffs"], [])
|
||||||
|
|
||||||
|
def test_unchanged_sample_limit_and_compact(self) -> None:
|
||||||
|
before = [
|
||||||
|
{"k": str(i), "v": "1", "_netx": {"row_id": f"b{i}"}} for i in range(5)
|
||||||
|
]
|
||||||
|
after = [
|
||||||
|
{"k": str(i), "v": "1", "_netx": {"row_id": f"a{i}"}} for i in range(5)
|
||||||
|
]
|
||||||
|
out = compare_rows(
|
||||||
|
before_rows=before,
|
||||||
|
after_rows=after,
|
||||||
|
key_fields=["k"],
|
||||||
|
iface_fields=[],
|
||||||
|
compare_fields=["v"],
|
||||||
|
port_map={},
|
||||||
|
include_unchanged=True,
|
||||||
|
unchanged_limit=2,
|
||||||
|
compact_unchanged=True,
|
||||||
|
)
|
||||||
|
self.assertEqual(out["summary"]["unchanged"], 5)
|
||||||
|
self.assertEqual(out["summary"]["unchanged_listed"], 2)
|
||||||
|
self.assertTrue(out["summary"]["unchanged_truncated"])
|
||||||
|
self.assertTrue(out["summary"]["unchanged_compact"])
|
||||||
|
self.assertEqual(len(out["diffs"]), 2)
|
||||||
|
self.assertEqual(out["diffs"][0]["before"], {})
|
||||||
|
self.assertEqual(out["diffs"][0]["after"], {})
|
||||||
|
self.assertEqual(out["diffs"][0]["before_row_id"], "b0")
|
||||||
|
self.assertEqual(out["diffs"][0]["after_row_id"], "a0")
|
||||||
|
|
||||||
def test_mac_normalize_via_field_rules(self) -> None:
|
def test_mac_normalize_via_field_rules(self) -> None:
|
||||||
before = [{"ip": "1.1.1.1", "mac": "00:11:22:33:44:55", "iface": "gei-0/1"}]
|
before = [{"ip": "1.1.1.1", "mac": "00:11:22:33:44:55", "iface": "gei-0/1"}]
|
||||||
|
|
@ -309,6 +359,57 @@ class CompareRulesTests(unittest.TestCase):
|
||||||
|
|
||||||
|
|
||||||
class CompareDiffPagingTests(unittest.TestCase):
|
class CompareDiffPagingTests(unittest.TestCase):
|
||||||
|
def test_resolve_unchanged_policy(self) -> None:
|
||||||
|
from netx_api.biz_state.compare_service import resolve_unchanged_policy
|
||||||
|
|
||||||
|
small = resolve_unchanged_policy("auto", before_n=100, after_n=100)
|
||||||
|
self.assertTrue(small["include"])
|
||||||
|
self.assertIsNone(small["limit"])
|
||||||
|
self.assertFalse(small["compact"])
|
||||||
|
large = resolve_unchanged_policy("auto", before_n=1_500_000, after_n=1_500_000)
|
||||||
|
self.assertTrue(large["include"])
|
||||||
|
self.assertEqual(large["limit"], 5000)
|
||||||
|
self.assertTrue(large["compact"])
|
||||||
|
self.assertFalse(resolve_unchanged_policy("never", before_n=10, after_n=10)["include"])
|
||||||
|
keys = resolve_unchanged_policy("keys", before_n=1_500_000, after_n=1_500_000)
|
||||||
|
self.assertTrue(keys["include"])
|
||||||
|
self.assertIsNone(keys["limit"])
|
||||||
|
self.assertTrue(keys["compact"])
|
||||||
|
|
||||||
|
def test_hydrate_diff_rows_fills_sides(self) -> None:
|
||||||
|
from netx_api.biz_state.compare_service import _hydrate_diff_rows
|
||||||
|
|
||||||
|
class _FakeDb:
|
||||||
|
pass
|
||||||
|
|
||||||
|
items = [
|
||||||
|
{
|
||||||
|
"kind": "unchanged",
|
||||||
|
"key": {"k": "1"},
|
||||||
|
"before": {},
|
||||||
|
"after": {},
|
||||||
|
"mapped_before": {},
|
||||||
|
"changes": {},
|
||||||
|
"before_row_id": "b1",
|
||||||
|
"after_row_id": "a1",
|
||||||
|
}
|
||||||
|
]
|
||||||
|
# Patch loader
|
||||||
|
import netx_api.biz_state.compare_service as cs
|
||||||
|
|
||||||
|
orig = cs._metric_rows_by_ids
|
||||||
|
try:
|
||||||
|
cs._metric_rows_by_ids = lambda _db, ids: { # type: ignore[assignment]
|
||||||
|
"b1": {"k": "1", "v": "pre"},
|
||||||
|
"a1": {"k": "1", "v": "post"},
|
||||||
|
}
|
||||||
|
out = _hydrate_diff_rows(_FakeDb(), items)
|
||||||
|
finally:
|
||||||
|
cs._metric_rows_by_ids = orig
|
||||||
|
self.assertEqual(out[0]["before"]["v"], "pre")
|
||||||
|
self.assertEqual(out[0]["after"]["v"], "post")
|
||||||
|
self.assertEqual(out[0]["mapped_before"]["v"], "pre")
|
||||||
|
|
||||||
def test_filter_inline_diffs_kind_and_kw(self) -> None:
|
def test_filter_inline_diffs_kind_and_kw(self) -> None:
|
||||||
from netx_api.biz_state.compare_service import _filter_inline_diffs
|
from netx_api.biz_state.compare_service import _filter_inline_diffs
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -494,6 +494,22 @@ const en = {
|
||||||
needBeforeBatch: "Select before task and batch",
|
needBeforeBatch: "Select before task and batch",
|
||||||
needAfterBatch: "Select after batch",
|
needAfterBatch: "Select after batch",
|
||||||
runNow: "Run now",
|
runNow: "Run now",
|
||||||
|
runStarted: "Compare started in background — you can close this page; check run history for progress",
|
||||||
|
runFailed: "Compare failed",
|
||||||
|
runStatusRunning: "Running",
|
||||||
|
runStatusFailed: "Failed",
|
||||||
|
runStatusDoneSec: "Done {{s}}s",
|
||||||
|
runProgress: "{{phase}} · sheet {{i}}/{{n}} · {{sheet}} · {{s}}s elapsed",
|
||||||
|
ranWithDuration: "Compare finished ({{s}}s)",
|
||||||
|
unchangedNotStored: "Success rows were counted but not stored. Set “Store success rows” to sample and re-run for spot-check.",
|
||||||
|
unchangedSampleHint: "{{total}} success rows total; browsing a sample of {{listed}} (hydrated from source tables for cutover spot-check). Pass rate uses all {{total}}.",
|
||||||
|
storeUnchanged: "Store success rows",
|
||||||
|
storeUnchangedAuto: "Auto (full if small / sample 5k + hydrate if large)",
|
||||||
|
storeUnchangedSample: "Sample only (up to 5k keys + hydrate)",
|
||||||
|
storeUnchangedKeys: "All success keys (full browse; large write still slow)",
|
||||||
|
storeUnchangedAlways: "Always full JSON (slow on huge sheets)",
|
||||||
|
storeUnchangedNever: "Never (counts only)",
|
||||||
|
storeUnchangedHint: "Cutover focuses on fails and pass rate; success defaults to a sample. Pass rate always uses the full success count.",
|
||||||
saveJob: "Save config",
|
saveJob: "Save config",
|
||||||
jobSaved: "Job config saved",
|
jobSaved: "Job config saved",
|
||||||
jobDeleted: "Compare job deleted",
|
jobDeleted: "Compare job deleted",
|
||||||
|
|
|
||||||
|
|
@ -493,6 +493,22 @@ const zh = {
|
||||||
needBeforeBatch: "请选择操作前任务与批次",
|
needBeforeBatch: "请选择操作前任务与批次",
|
||||||
needAfterBatch: "请选择操作后批次",
|
needAfterBatch: "请选择操作后批次",
|
||||||
runNow: "立即比对",
|
runNow: "立即比对",
|
||||||
|
runStarted: "比对已在后台启动,可关闭本页;进度见历史记录",
|
||||||
|
runFailed: "比对失败",
|
||||||
|
runStatusRunning: "比对中",
|
||||||
|
runStatusFailed: "失败",
|
||||||
|
runStatusDoneSec: "完成 {{s}}s",
|
||||||
|
runProgress: "{{phase}} · 表 {{i}}/{{n}} · {{sheet}} · 已用 {{s}}s",
|
||||||
|
ranWithDuration: "比对完成(耗时 {{s}} 秒)",
|
||||||
|
unchangedNotStored: "成功行仅统计数量未落库。可在任务配置将「成功行保存」改为抽样后重新比对(抽查用)。",
|
||||||
|
unchangedSampleHint: "成功共 {{total}} 条,明细抽样 {{listed}} 条(从原表补全显示,割接抽查用)。通过率按全部 {{total}} 计。",
|
||||||
|
storeUnchanged: "成功行保存",
|
||||||
|
storeUnchangedAuto: "自动(小表全量 / 大表抽样 5000+原表补全)",
|
||||||
|
storeUnchangedSample: "仅抽样(最多 5000,身份键+原表补全)",
|
||||||
|
storeUnchangedKeys: "全量身份键(可翻完全部成功,大表写入仍较久)",
|
||||||
|
storeUnchangedAlways: "始终全量 JSON(大表很慢,慎用)",
|
||||||
|
storeUnchangedNever: "不保存(只计数量)",
|
||||||
|
storeUnchangedHint: "割接优先看失败与通过率;成功默认抽样抽查。通过率始终按全部成功计数。",
|
||||||
saveJob: "保存配置",
|
saveJob: "保存配置",
|
||||||
jobSaved: "任务配置已保存",
|
jobSaved: "任务配置已保存",
|
||||||
jobDeleted: "比对任务已删除",
|
jobDeleted: "比对任务已删除",
|
||||||
|
|
|
||||||
|
|
@ -11698,6 +11698,29 @@ html.login-page--paused .login-page__flare {
|
||||||
rgba(8, 13, 24, 0.4);
|
rgba(8, 13, 24, 0.4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.bs-cmp-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: baseline;
|
||||||
|
gap: 8px 14px;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||||
|
background: rgba(37, 99, 235, 0.12);
|
||||||
|
color: var(--bs-cmp-ink, #e2e8f0);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-cmp-progress.is-failed {
|
||||||
|
border-color: rgba(239, 68, 68, 0.4);
|
||||||
|
background: rgba(239, 68, 68, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bs-cmp-progress__msg {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
font-size: 0.8125rem;
|
||||||
|
}
|
||||||
|
|
||||||
/* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */
|
/* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */
|
||||||
.bs-cmp-board.is-fullscreen,
|
.bs-cmp-board.is-fullscreen,
|
||||||
.bs-cmp-board:fullscreen {
|
.bs-cmp-board:fullscreen {
|
||||||
|
|
|
||||||
|
|
@ -100,6 +100,8 @@ type Template = {
|
||||||
};
|
};
|
||||||
|
|
||||||
type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] };
|
type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] };
|
||||||
|
type StoreUnchanged = "auto" | "always" | "never" | "sample" | "keys";
|
||||||
|
|
||||||
type Job = {
|
type Job = {
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
|
@ -112,6 +114,7 @@ type Job = {
|
||||||
mode: string;
|
mode: string;
|
||||||
status: string;
|
status: string;
|
||||||
enabled_sheet_ids?: string[];
|
enabled_sheet_ids?: string[];
|
||||||
|
store_unchanged?: StoreUnchanged | string;
|
||||||
note?: string;
|
note?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -824,6 +827,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
const [beforeBatchId, setBeforeBatchId] = useState("");
|
const [beforeBatchId, setBeforeBatchId] = useState("");
|
||||||
const [afterBatchId, setAfterBatchId] = useState("");
|
const [afterBatchId, setAfterBatchId] = useState("");
|
||||||
const [mode, setMode] = useState<"manual" | "auto">("manual");
|
const [mode, setMode] = useState<"manual" | "auto">("manual");
|
||||||
|
const [storeUnchanged, setStoreUnchanged] = useState<StoreUnchanged>("auto");
|
||||||
const [mapName, setMapName] = useState("端口映射");
|
const [mapName, setMapName] = useState("端口映射");
|
||||||
const [mapText, setMapText] = useState("");
|
const [mapText, setMapText] = useState("");
|
||||||
const [validateOut, setValidateOut] = useState<any>(null);
|
const [validateOut, setValidateOut] = useState<any>(null);
|
||||||
|
|
@ -1056,7 +1060,14 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const runId = String(runDetail?.id || "");
|
const runId = String(runDetail?.id || "");
|
||||||
const mid = resultSheetId || sheetIdentity(activeRunSheet) || "";
|
const mid = resultSheetId || sheetIdentity(activeRunSheet) || "";
|
||||||
if (!runId || !mid || jobDetailTab !== "result") {
|
const st = String(runDetail?.status || "");
|
||||||
|
if (
|
||||||
|
!runId ||
|
||||||
|
!mid ||
|
||||||
|
jobDetailTab !== "result" ||
|
||||||
|
st === "running" ||
|
||||||
|
st === "queued"
|
||||||
|
) {
|
||||||
setPagedDiffs([]);
|
setPagedDiffs([]);
|
||||||
setResultTotal(0);
|
setResultTotal(0);
|
||||||
return;
|
return;
|
||||||
|
|
@ -1095,6 +1106,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
};
|
};
|
||||||
}, [
|
}, [
|
||||||
runDetail?.id,
|
runDetail?.id,
|
||||||
|
runDetail?.status,
|
||||||
resultSheetId,
|
resultSheetId,
|
||||||
activeRunSheet?.metric_id,
|
activeRunSheet?.metric_id,
|
||||||
kindFilter,
|
kindFilter,
|
||||||
|
|
@ -1649,6 +1661,14 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
setBeforeBatchId(preset?.before_batch_id || "");
|
setBeforeBatchId(preset?.before_batch_id || "");
|
||||||
setAfterBatchId(preset?.after_batch_id || "");
|
setAfterBatchId(preset?.after_batch_id || "");
|
||||||
setMode(preset?.mode === "auto" ? "auto" : "manual");
|
setMode(preset?.mode === "auto" ? "auto" : "manual");
|
||||||
|
{
|
||||||
|
const su = String(preset?.store_unchanged || "auto");
|
||||||
|
setStoreUnchanged(
|
||||||
|
su === "always" || su === "never" || su === "sample" || su === "keys"
|
||||||
|
? su
|
||||||
|
: "auto",
|
||||||
|
);
|
||||||
|
}
|
||||||
setValidateOut(null);
|
setValidateOut(null);
|
||||||
if (preset?.mapping_id) loadMappingText(preset.mapping_id);
|
if (preset?.mapping_id) loadMappingText(preset.mapping_id);
|
||||||
else {
|
else {
|
||||||
|
|
@ -1667,8 +1687,24 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
after_batch_id: mode === "manual" ? afterBatchId : "",
|
after_batch_id: mode === "manual" ? afterBatchId : "",
|
||||||
mode,
|
mode,
|
||||||
enabled_sheet_ids: enabledSheetIds,
|
enabled_sheet_ids: enabledSheetIds,
|
||||||
|
store_unchanged: storeUnchanged,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const renderStoreUnchangedField = () => (
|
||||||
|
<FieldSelect
|
||||||
|
label={t("bizCompare.storeUnchanged")}
|
||||||
|
value={storeUnchanged}
|
||||||
|
onChange={(e) => setStoreUnchanged(e.target.value as StoreUnchanged)}
|
||||||
|
fullWidth
|
||||||
|
>
|
||||||
|
<option value="auto">{t("bizCompare.storeUnchangedAuto")}</option>
|
||||||
|
<option value="sample">{t("bizCompare.storeUnchangedSample")}</option>
|
||||||
|
<option value="keys">{t("bizCompare.storeUnchangedKeys")}</option>
|
||||||
|
<option value="always">{t("bizCompare.storeUnchangedAlways")}</option>
|
||||||
|
<option value="never">{t("bizCompare.storeUnchangedNever")}</option>
|
||||||
|
</FieldSelect>
|
||||||
|
);
|
||||||
|
|
||||||
const closeCreateJob = () => {
|
const closeCreateJob = () => {
|
||||||
setJobCreateOpen(false);
|
setJobCreateOpen(false);
|
||||||
setCreateStep(0);
|
setCreateStep(0);
|
||||||
|
|
@ -1805,10 +1841,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
try {
|
try {
|
||||||
await bizCompareUpdateJob(jobId, jobConfigBody());
|
await bizCompareUpdateJob(jobId, jobConfigBody());
|
||||||
|
// Async enqueue — returns immediately with status=running; poll below.
|
||||||
const run = await bizCompareRunJob(jobId);
|
const run = await bizCompareRunJob(jobId);
|
||||||
setRunDetail(run);
|
setRunDetail(run);
|
||||||
setJobDetailTab("result");
|
setJobDetailTab("result");
|
||||||
showOk(t("bizCompare.ran"));
|
showOk(t("bizCompare.runStarted"));
|
||||||
const r = await bizCompareListRuns(jobId);
|
const r = await bizCompareListRuns(jobId);
|
||||||
setRuns(r.items || []);
|
setRuns(r.items || []);
|
||||||
await refresh({ force: true });
|
await refresh({ force: true });
|
||||||
|
|
@ -1819,6 +1856,56 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const runStatus = String(runDetail?.status || "");
|
||||||
|
const runIsActive = runStatus === "running" || runStatus === "queued";
|
||||||
|
const runProgress = (runDetail?.summary?.progress || {}) as {
|
||||||
|
phase?: string;
|
||||||
|
sheet_index?: number;
|
||||||
|
sheet_total?: number;
|
||||||
|
sheet_title?: string;
|
||||||
|
sheet_id?: string;
|
||||||
|
elapsed_ms?: number;
|
||||||
|
before_count?: number;
|
||||||
|
after_count?: number;
|
||||||
|
diff_rows?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Poll active compare runs so the modal can be closed and reopened safely.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!runIsActive || !runDetail?.id) return;
|
||||||
|
let cancelled = false;
|
||||||
|
let notified = false;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const d = await bizCompareGetRun(String(runDetail.id));
|
||||||
|
if (cancelled) return;
|
||||||
|
setRunDetail(d);
|
||||||
|
if (jobId) {
|
||||||
|
const r = await bizCompareListRuns(jobId);
|
||||||
|
if (!cancelled) setRuns(r.items || []);
|
||||||
|
}
|
||||||
|
const st = String(d.status || "");
|
||||||
|
if (!notified && st === "success") {
|
||||||
|
notified = true;
|
||||||
|
const ms = Number((d.summary as any)?.duration_ms || 0);
|
||||||
|
const sec = ms > 0 ? Math.round(ms / 1000) : 0;
|
||||||
|
showOk(sec > 0 ? t("bizCompare.ranWithDuration", { s: String(sec) }) : t("bizCompare.ran"));
|
||||||
|
} else if (!notified && st === "failed") {
|
||||||
|
notified = true;
|
||||||
|
showError(String(d.message || t("bizCompare.runFailed")));
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
if (!cancelled) showError(formatErr(e));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void tick();
|
||||||
|
const id = window.setInterval(() => void tick(), 2000);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, [runDetail?.id, runIsActive, jobId, showOk, showError, t]);
|
||||||
|
|
||||||
const loadRun = async (runId: string) => {
|
const loadRun = async (runId: string) => {
|
||||||
try {
|
try {
|
||||||
const d = await bizCompareGetRun(runId);
|
const d = await bizCompareGetRun(runId);
|
||||||
|
|
@ -2034,6 +2121,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<option value="manual">{t("bizCompare.modeManual")}</option>
|
<option value="manual">{t("bizCompare.modeManual")}</option>
|
||||||
<option value="auto">{t("bizCompare.modeAuto")}</option>
|
<option value="auto">{t("bizCompare.modeAuto")}</option>
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
|
{renderStoreUnchangedField()}
|
||||||
|
<p className="muted bm-hint">{t("bizCompare.storeUnchangedHint")}</p>
|
||||||
{renderSheetChips()}
|
{renderSheetChips()}
|
||||||
{renderBatchFields()}
|
{renderBatchFields()}
|
||||||
{renderMappingBlock()}
|
{renderMappingBlock()}
|
||||||
|
|
@ -2701,6 +2790,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<option value="manual">{t("bizCompare.modeManual")}</option>
|
<option value="manual">{t("bizCompare.modeManual")}</option>
|
||||||
<option value="auto">{t("bizCompare.modeAuto")}</option>
|
<option value="auto">{t("bizCompare.modeAuto")}</option>
|
||||||
</FieldSelect>
|
</FieldSelect>
|
||||||
|
{renderStoreUnchangedField()}
|
||||||
|
<p className="muted bm-hint">{t("bizCompare.storeUnchangedHint")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
|
@ -2854,8 +2945,21 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
const bl = sideDeviceName(before);
|
const bl = sideDeviceName(before);
|
||||||
const al = sideDeviceName(after);
|
const al = sideDeviceName(after);
|
||||||
const when = formatSystemTime((r as any).created_at) || "";
|
const when = formatSystemTime((r as any).created_at) || "";
|
||||||
|
const st = String((r as any).status || "");
|
||||||
|
const durMs = Number((r as any).summary?.duration_ms || 0);
|
||||||
|
const stLabel =
|
||||||
|
st === "running" || st === "queued"
|
||||||
|
? t("bizCompare.runStatusRunning")
|
||||||
|
: st === "failed"
|
||||||
|
? t("bizCompare.runStatusFailed")
|
||||||
|
: durMs > 0
|
||||||
|
? t("bizCompare.runStatusDoneSec", {
|
||||||
|
s: String(Math.round(durMs / 1000)),
|
||||||
|
})
|
||||||
|
: "";
|
||||||
return (
|
return (
|
||||||
<option key={r.id} value={r.id}>
|
<option key={r.id} value={r.id}>
|
||||||
|
{stLabel ? `[${stLabel}] ` : ""}
|
||||||
{when ? `${when} · ` : ""}
|
{when ? `${when} · ` : ""}
|
||||||
{bl} {sideCollectTime(before)} → {al} {sideCollectTime(after)}
|
{bl} {sideCollectTime(before)} → {al} {sideCollectTime(after)}
|
||||||
</option>
|
</option>
|
||||||
|
|
@ -2876,7 +2980,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
isDisabled={busy || !runDetail?.id}
|
isDisabled={busy || runIsActive || !runDetail?.id}
|
||||||
onPress={() => void downloadRunTables()}
|
onPress={() => void downloadRunTables()}
|
||||||
>
|
>
|
||||||
{t("bizCompare.exportTables")}
|
{t("bizCompare.exportTables")}
|
||||||
|
|
@ -2884,7 +2988,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
isDisabled={busy || !runDetail?.id}
|
isDisabled={busy || runIsActive || !runDetail?.id}
|
||||||
onPress={() => void removeRun(String(runDetail?.id || ""))}
|
onPress={() => void removeRun(String(runDetail?.id || ""))}
|
||||||
>
|
>
|
||||||
{t("bizCompare.deleteRun")}
|
{t("bizCompare.deleteRun")}
|
||||||
|
|
@ -2902,6 +3006,31 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{runDetail && runIsActive ? (
|
||||||
|
<div className="bs-cmp-progress" role="status" aria-live="polite">
|
||||||
|
<strong>{t("bizCompare.runStatusRunning")}</strong>
|
||||||
|
<span className="muted">
|
||||||
|
{t("bizCompare.runProgress", {
|
||||||
|
phase: String(runProgress.phase || "…"),
|
||||||
|
i: String(runProgress.sheet_index || 0),
|
||||||
|
n: String(runProgress.sheet_total || 0),
|
||||||
|
sheet: String(
|
||||||
|
runProgress.sheet_title || runProgress.sheet_id || "—",
|
||||||
|
),
|
||||||
|
s: String(Math.round(Number(runProgress.elapsed_ms || 0) / 1000)),
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
{runDetail.message ? (
|
||||||
|
<span className="muted bs-cmp-progress__msg">{String(runDetail.message)}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{runDetail && runStatus === "failed" ? (
|
||||||
|
<div className="bs-cmp-progress is-failed" role="alert">
|
||||||
|
<strong>{t("bizCompare.runStatusFailed")}</strong>
|
||||||
|
<span>{String(runDetail.message || "")}</span>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
{runDetail ? (
|
{runDetail ? (
|
||||||
<div
|
<div
|
||||||
className={`bs-cmp-board__body${navCollapsed ? " is-nav-collapsed" : ""}`}
|
className={`bs-cmp-board__body${navCollapsed ? " is-nav-collapsed" : ""}`}
|
||||||
|
|
@ -3101,6 +3230,30 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
{diffsLoading ? "…" : `${pagedDiffs.length}/${resultTotal}`}
|
{diffsLoading ? "…" : `${pagedDiffs.length}/${resultTotal}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
{kindFilter === "unchanged" &&
|
||||||
|
(runDetail?.summary?.unchanged_truncated ||
|
||||||
|
(Number(runDetail?.summary?.unchanged || 0) >
|
||||||
|
Number(runDetail?.summary?.unchanged_listed || 0) &&
|
||||||
|
Number(runDetail?.summary?.unchanged_listed || 0) > 0)) ? (
|
||||||
|
<p className="muted bm-hint" style={{ margin: "4px 0 0" }}>
|
||||||
|
{t("bizCompare.unchangedSampleHint", {
|
||||||
|
listed: String(
|
||||||
|
Number(
|
||||||
|
(activeRunSheet?.summary as any)?.unchanged_listed ??
|
||||||
|
runDetail?.summary?.unchanged_listed ??
|
||||||
|
resultTotal,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
total: String(
|
||||||
|
Number(
|
||||||
|
(activeRunSheet?.summary as any)?.unchanged ??
|
||||||
|
runDetail?.summary?.unchanged ??
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
})}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
|
|
@ -3287,7 +3440,16 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
{runDetail && !diffsLoading && !pagedDiffs.length ? (
|
{runDetail && !diffsLoading && !pagedDiffs.length ? (
|
||||||
<tr>
|
<tr>
|
||||||
<td colSpan={resultEmptyColSpan}>
|
<td colSpan={resultEmptyColSpan}>
|
||||||
<div className="pt-list-empty">{t("bizCompare.resultEmpty")}</div>
|
<div className="pt-list-empty">
|
||||||
|
{kindFilter === "unchanged"
|
||||||
|
? Number(runDetail?.summary?.unchanged || 0) > 0 &&
|
||||||
|
!Number(runDetail?.summary?.unchanged_listed || 0)
|
||||||
|
? t("bizCompare.unchangedNotStored")
|
||||||
|
: t("bizCompare.resultEmpty")
|
||||||
|
: runIsActive
|
||||||
|
? t("bizCompare.runStatusRunning")
|
||||||
|
: t("bizCompare.resultEmpty")}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -3322,8 +3484,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void saveJobConfig()}>
|
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void saveJobConfig()}>
|
||||||
{t("bizCompare.saveJob")}
|
{t("bizCompare.saveJob")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void runNow()}>
|
<Button
|
||||||
{t("bizCompare.runNow")}
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
isDisabled={busy || runIsActive}
|
||||||
|
onPress={() => void runNow()}
|
||||||
|
>
|
||||||
|
{runIsActive ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||||||
</Button>
|
</Button>
|
||||||
{jobId ? (
|
{jobId ? (
|
||||||
<Button size="sm" variant="danger" isDisabled={busy} onPress={() => void removeJob(jobId)}>
|
<Button size="sm" variant="danger" isDisabled={busy} onPress={() => void removeJob(jobId)}>
|
||||||
|
|
@ -3349,8 +3516,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
>
|
>
|
||||||
{t("bizCompare.deleteRun")}
|
{t("bizCompare.deleteRun")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void runNow()}>
|
<Button
|
||||||
{t("bizCompare.runNow")}
|
size="sm"
|
||||||
|
variant="primary"
|
||||||
|
isDisabled={busy || runIsActive}
|
||||||
|
onPress={() => void runNow()}
|
||||||
|
>
|
||||||
|
{runIsActive ? t("bizCompare.runStatusRunning") : t("bizCompare.runNow")}
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue