mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 00:50:46 +08:00
Add collect stop, cutover nav prefetch/cache, and lane labels.
Let operators abort queued/running biz_state batches, speed cutover page switches, and show light/heavy collect lanes on monitor items. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
305822aa4f
commit
924f9bd2f7
16 changed files with 779 additions and 112 deletions
|
|
@ -45,6 +45,13 @@ from .command_match import (
|
||||||
)
|
)
|
||||||
from .parsers import get_parser
|
from .parsers import get_parser
|
||||||
from .profiles import get_profile
|
from .profiles import get_profile
|
||||||
|
from .collect_stop import (
|
||||||
|
STOP_USER_MESSAGE,
|
||||||
|
clear_stop_requested,
|
||||||
|
is_stop_requested,
|
||||||
|
register_lane_holder,
|
||||||
|
unregister_lane_holder,
|
||||||
|
)
|
||||||
|
|
||||||
_log = logging.getLogger("netx.biz_state.runner")
|
_log = logging.getLogger("netx.biz_state.runner")
|
||||||
|
|
||||||
|
|
@ -457,17 +464,32 @@ def execute_claimed_batch(
|
||||||
|
|
||||||
error = ""
|
error = ""
|
||||||
try:
|
try:
|
||||||
_run_collect_session(
|
if is_stop_requested(batch_id):
|
||||||
task_id=task_id,
|
_finalize_batch_status(
|
||||||
batch_id=batch_id,
|
batch_id=batch_id,
|
||||||
source=source,
|
task_id=task_id,
|
||||||
ne_id=ne_id,
|
cmd_count=0,
|
||||||
vendor=vendor,
|
total_rows=0,
|
||||||
device_type=device_type,
|
any_fail=False,
|
||||||
)
|
any_ok=False,
|
||||||
|
lane_errors=[],
|
||||||
|
stopped=True,
|
||||||
|
)
|
||||||
|
error = STOP_USER_MESSAGE
|
||||||
|
else:
|
||||||
|
_run_collect_session(
|
||||||
|
task_id=task_id,
|
||||||
|
batch_id=batch_id,
|
||||||
|
source=source,
|
||||||
|
ne_id=ne_id,
|
||||||
|
vendor=vendor,
|
||||||
|
device_type=device_type,
|
||||||
|
)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
_log.exception("biz_state collect failed task=%s batch=%s", task_id, batch_id)
|
_log.exception("biz_state collect failed task=%s batch=%s", task_id, batch_id)
|
||||||
error = _format_error(exc)
|
error = _format_error(exc)
|
||||||
|
if "_stopped" in error or is_stop_requested(batch_id):
|
||||||
|
error = STOP_USER_MESSAGE
|
||||||
try:
|
try:
|
||||||
_fail_batch_status(batch_id, error)
|
_fail_batch_status(batch_id, error)
|
||||||
except Exception:
|
except Exception:
|
||||||
|
|
@ -509,10 +531,14 @@ def _run_collect_lane(
|
||||||
budget = min(int(cap), int(per_cmd) * max(1, len(work)) + 90)
|
budget = min(int(cap), int(per_cmd) * max(1, len(work)) + 90)
|
||||||
holder: dict[str, Any] = {}
|
holder: dict[str, Any] = {}
|
||||||
flush_every = persist_every_cmds()
|
flush_every = persist_every_cmds()
|
||||||
|
register_lane_holder(batch_id, holder)
|
||||||
|
|
||||||
def _session() -> tuple[int, int, bool, bool]:
|
def _session() -> tuple[int, int, bool, bool]:
|
||||||
from ..ne_netmiko import drain_read_channel
|
from ..ne_netmiko import drain_read_channel
|
||||||
|
|
||||||
|
if is_stop_requested(batch_id):
|
||||||
|
raise TimeoutError(f"{label}_stopped")
|
||||||
|
|
||||||
conn = open_netmiko_connection(creds, session_timeout=budget)
|
conn = open_netmiko_connection(creds, session_timeout=budget)
|
||||||
holder["conn"] = conn
|
holder["conn"] = conn
|
||||||
total_rows = 0
|
total_rows = 0
|
||||||
|
|
@ -636,8 +662,10 @@ def _run_collect_lane(
|
||||||
flat_work.append((cmd, p, profile_id, item_id, "normal"))
|
flat_work.append((cmd, p, profile_id, item_id, "normal"))
|
||||||
|
|
||||||
for concrete, params, profile_id, item_id, mode in flat_work:
|
for concrete, params, profile_id, item_id, mode in flat_work:
|
||||||
if holder.get("timed_out"):
|
if holder.get("timed_out") or holder.get("stop_requested") or is_stop_requested(
|
||||||
raise TimeoutError(f"{label}_aborted")
|
batch_id
|
||||||
|
):
|
||||||
|
raise TimeoutError(f"{label}_stopped")
|
||||||
|
|
||||||
cmd_id = uuid4().hex
|
cmd_id = uuid4().hex
|
||||||
raw_text = ""
|
raw_text = ""
|
||||||
|
|
@ -923,6 +951,8 @@ def _run_collect_lane(
|
||||||
)
|
)
|
||||||
except TimeoutError as exc:
|
except TimeoutError as exc:
|
||||||
raise RuntimeError(str(exc)[:1020]) from exc
|
raise RuntimeError(str(exc)[:1020]) from exc
|
||||||
|
finally:
|
||||||
|
unregister_lane_holder(batch_id, holder)
|
||||||
|
|
||||||
|
|
||||||
def _absorb_lane_result(
|
def _absorb_lane_result(
|
||||||
|
|
@ -1052,6 +1082,7 @@ def _finalize_batch_status(
|
||||||
any_fail: bool,
|
any_fail: bool,
|
||||||
any_ok: bool,
|
any_ok: bool,
|
||||||
lane_errors: list[str],
|
lane_errors: list[str],
|
||||||
|
stopped: bool = False,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""Write terminal batch status on a fresh Session (retry once on disconnect)."""
|
"""Write terminal batch status on a fresh Session (retry once on disconnect)."""
|
||||||
|
|
||||||
|
|
@ -1063,7 +1094,14 @@ def _finalize_batch_status(
|
||||||
batch.command_count = max(int(batch.command_count or 0), int(cmd_count or 0))
|
batch.command_count = max(int(batch.command_count or 0), int(cmd_count or 0))
|
||||||
batch.row_count = max(int(batch.row_count or 0), int(total_rows or 0))
|
batch.row_count = max(int(batch.row_count or 0), int(total_rows or 0))
|
||||||
batch.ended_at = _utcnow()
|
batch.ended_at = _utcnow()
|
||||||
if any_fail and any_ok:
|
if stopped:
|
||||||
|
if any_ok or int(batch.command_count or 0) > 0 or int(batch.row_count or 0) > 0:
|
||||||
|
batch.status = "partial"
|
||||||
|
batch.message = STOP_USER_MESSAGE
|
||||||
|
else:
|
||||||
|
batch.status = "cancelled"
|
||||||
|
batch.message = STOP_USER_MESSAGE
|
||||||
|
elif any_fail and any_ok:
|
||||||
batch.status = "partial"
|
batch.status = "partial"
|
||||||
if lane_errors:
|
if lane_errors:
|
||||||
batch.message = "; ".join(lane_errors)[:1020]
|
batch.message = "; ".join(lane_errors)[:1020]
|
||||||
|
|
@ -1077,7 +1115,7 @@ def _finalize_batch_status(
|
||||||
batch.message = ""
|
batch.message = ""
|
||||||
status = str(batch.status or "")
|
status = str(batch.status or "")
|
||||||
db.commit()
|
db.commit()
|
||||||
if status in ("success", "partial"):
|
if status in ("success", "partial") and not stopped:
|
||||||
try:
|
try:
|
||||||
from .compare_service import try_auto_compare_for_task
|
from .compare_service import try_auto_compare_for_task
|
||||||
|
|
||||||
|
|
@ -1086,28 +1124,40 @@ def _finalize_batch_status(
|
||||||
_log.exception("biz_state auto compare hook failed task=%s", task_id)
|
_log.exception("biz_state auto compare hook failed task=%s", task_id)
|
||||||
return status
|
return status
|
||||||
|
|
||||||
return str(
|
try:
|
||||||
_run_db_with_reconnect(_write, label="biz_state_finalize") or ""
|
return str(
|
||||||
)
|
_run_db_with_reconnect(_write, label="biz_state_finalize") or ""
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
clear_stop_requested(batch_id)
|
||||||
|
|
||||||
|
|
||||||
def _fail_batch_status(batch_id: str, error: str) -> None:
|
def _fail_batch_status(batch_id: str, error: str) -> None:
|
||||||
"""Mark batch failed on a fresh Session (retry once on disconnect)."""
|
"""Mark batch failed on a fresh Session (retry once on disconnect)."""
|
||||||
msg = str(error or "")[:1020]
|
msg = str(error or "")[:1020]
|
||||||
|
stopped = "_stopped" in msg or is_stop_requested(batch_id)
|
||||||
|
|
||||||
def _write(db) -> None:
|
def _write(db) -> None:
|
||||||
batch = db.get(BizStateBatch, batch_id)
|
batch = db.get(BizStateBatch, batch_id)
|
||||||
if not batch:
|
if not batch:
|
||||||
return
|
return
|
||||||
if str(batch.status or "") != "running":
|
st = str(batch.status or "")
|
||||||
|
if st not in ("running", "queued"):
|
||||||
return
|
return
|
||||||
batch.status = "failed"
|
if stopped:
|
||||||
batch.message = msg
|
has_progress = int(batch.command_count or 0) > 0 or int(batch.row_count or 0) > 0
|
||||||
|
batch.status = "partial" if has_progress else "cancelled"
|
||||||
|
batch.message = STOP_USER_MESSAGE
|
||||||
|
else:
|
||||||
|
batch.status = "failed"
|
||||||
|
batch.message = msg
|
||||||
batch.ended_at = _utcnow()
|
batch.ended_at = _utcnow()
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
_run_db_with_reconnect(_write, label="biz_state_fail_batch")
|
try:
|
||||||
|
_run_db_with_reconnect(_write, label="biz_state_fail_batch")
|
||||||
|
finally:
|
||||||
|
clear_stop_requested(batch_id)
|
||||||
|
|
||||||
def _run_collect_session(
|
def _run_collect_session(
|
||||||
*,
|
*,
|
||||||
|
|
@ -1340,6 +1390,18 @@ def _run_collect_session(
|
||||||
if lane_errors and not any_ok and cmd_count == 0:
|
if lane_errors and not any_ok and cmd_count == 0:
|
||||||
# Progressive bumps may already have cmds; only hard-fail if nothing landed.
|
# Progressive bumps may already have cmds; only hard-fail if nothing landed.
|
||||||
if not _batch_has_progress(batch_id):
|
if not _batch_has_progress(batch_id):
|
||||||
|
if is_stop_requested(batch_id) or any("_stopped" in e for e in lane_errors):
|
||||||
|
_finalize_batch_status(
|
||||||
|
batch_id=batch_id,
|
||||||
|
task_id=task_id,
|
||||||
|
cmd_count=cmd_count,
|
||||||
|
total_rows=total_rows,
|
||||||
|
any_fail=False,
|
||||||
|
any_ok=False,
|
||||||
|
lane_errors=lane_errors,
|
||||||
|
stopped=True,
|
||||||
|
)
|
||||||
|
return
|
||||||
raise RuntimeError("; ".join(lane_errors)[:1020])
|
raise RuntimeError("; ".join(lane_errors)[:1020])
|
||||||
any_fail = True
|
any_fail = True
|
||||||
|
|
||||||
|
|
@ -1358,6 +1420,7 @@ def _run_collect_session(
|
||||||
except Exception:
|
except Exception:
|
||||||
_log.exception("biz_state persist barrier before finalize failed batch=%s", batch_id)
|
_log.exception("biz_state persist barrier before finalize failed batch=%s", batch_id)
|
||||||
|
|
||||||
|
stopped = is_stop_requested(batch_id) or any("_stopped" in e for e in lane_errors)
|
||||||
_finalize_batch_status(
|
_finalize_batch_status(
|
||||||
batch_id=batch_id,
|
batch_id=batch_id,
|
||||||
task_id=task_id,
|
task_id=task_id,
|
||||||
|
|
@ -1366,6 +1429,7 @@ def _run_collect_session(
|
||||||
any_fail=any_fail,
|
any_fail=any_fail,
|
||||||
any_ok=any_ok,
|
any_ok=any_ok,
|
||||||
lane_errors=lane_errors,
|
lane_errors=lane_errors,
|
||||||
|
stopped=stopped,
|
||||||
)
|
)
|
||||||
|
|
||||||
def _purge(db) -> None:
|
def _purge(db) -> None:
|
||||||
|
|
|
||||||
221
netx_api/biz_state/collect_stop.py
Normal file
221
netx_api/biz_state/collect_stop.py
Normal file
|
|
@ -0,0 +1,221 @@
|
||||||
|
"""Stop an in-flight / queued biz_state collect batch.
|
||||||
|
|
||||||
|
Queued batches are cancelled in DB immediately. Running lanes poll
|
||||||
|
``is_stop_requested`` between commands (DB-backed for dedicated workers)
|
||||||
|
and process-local holders are force-closed when stop is requested in the
|
||||||
|
same process.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import logging
|
||||||
|
import threading
|
||||||
|
import time
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from ..db import SessionLocal
|
||||||
|
from ..models import BizStateBatch, BizStateTask
|
||||||
|
from ..ne_session_factory import close_netmiko_connection
|
||||||
|
from ..timeutil import utcnow_naive
|
||||||
|
|
||||||
|
_log = logging.getLogger("netx.biz_state.collect_stop")
|
||||||
|
|
||||||
|
STOP_REQUEST_TOKEN = "stop_requested"
|
||||||
|
STOP_USER_MESSAGE = "stopped_by_user"
|
||||||
|
|
||||||
|
_lock = threading.Lock()
|
||||||
|
_stop_batches: set[str] = set()
|
||||||
|
_holders: dict[str, list[dict[str, Any]]] = {}
|
||||||
|
# batch_id → (monotonic_ts, stop_requested)
|
||||||
|
_db_cache: dict[str, tuple[float, bool]] = {}
|
||||||
|
_DB_CACHE_TTL_SEC = 1.5
|
||||||
|
|
||||||
|
|
||||||
|
def register_lane_holder(batch_id: str, holder: dict[str, Any]) -> None:
|
||||||
|
bid = str(batch_id or "").strip()
|
||||||
|
if not bid or holder is None:
|
||||||
|
return
|
||||||
|
with _lock:
|
||||||
|
_holders.setdefault(bid, []).append(holder)
|
||||||
|
|
||||||
|
|
||||||
|
def unregister_lane_holder(batch_id: str, holder: dict[str, Any]) -> None:
|
||||||
|
bid = str(batch_id or "").strip()
|
||||||
|
if not bid:
|
||||||
|
return
|
||||||
|
with _lock:
|
||||||
|
lst = _holders.get(bid) or []
|
||||||
|
try:
|
||||||
|
lst.remove(holder)
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
if not lst:
|
||||||
|
_holders.pop(bid, None)
|
||||||
|
|
||||||
|
|
||||||
|
def mark_stop_requested(batch_id: str) -> None:
|
||||||
|
bid = str(batch_id or "").strip()
|
||||||
|
if not bid:
|
||||||
|
return
|
||||||
|
with _lock:
|
||||||
|
_stop_batches.add(bid)
|
||||||
|
_db_cache[bid] = (time.monotonic(), True)
|
||||||
|
|
||||||
|
|
||||||
|
def clear_stop_requested(batch_id: str) -> None:
|
||||||
|
bid = str(batch_id or "").strip()
|
||||||
|
if not bid:
|
||||||
|
return
|
||||||
|
with _lock:
|
||||||
|
_stop_batches.discard(bid)
|
||||||
|
_db_cache.pop(bid, None)
|
||||||
|
_holders.pop(bid, None)
|
||||||
|
|
||||||
|
|
||||||
|
def is_stop_requested(batch_id: str) -> bool:
|
||||||
|
"""True if user asked to stop this batch (memory or DB)."""
|
||||||
|
bid = str(batch_id or "").strip()
|
||||||
|
if not bid:
|
||||||
|
return False
|
||||||
|
with _lock:
|
||||||
|
if bid in _stop_batches:
|
||||||
|
return True
|
||||||
|
hit = _db_cache.get(bid)
|
||||||
|
now = time.monotonic()
|
||||||
|
if hit and (now - hit[0]) < _DB_CACHE_TTL_SEC:
|
||||||
|
return bool(hit[1])
|
||||||
|
|
||||||
|
stopped = _read_stop_from_db(bid)
|
||||||
|
with _lock:
|
||||||
|
_db_cache[bid] = (time.monotonic(), stopped)
|
||||||
|
if stopped:
|
||||||
|
_stop_batches.add(bid)
|
||||||
|
return stopped
|
||||||
|
|
||||||
|
|
||||||
|
def _read_stop_from_db(batch_id: str) -> bool:
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
batch = db.get(BizStateBatch, batch_id)
|
||||||
|
if not batch:
|
||||||
|
return False
|
||||||
|
st = str(batch.status or "")
|
||||||
|
if st in ("cancelled", "success", "partial", "failed"):
|
||||||
|
# Already terminal — treat as stop so lanes exit quickly.
|
||||||
|
return st == "cancelled" or STOP_REQUEST_TOKEN in str(batch.message or "")
|
||||||
|
msg = str(batch.message or "")
|
||||||
|
return msg.startswith(STOP_REQUEST_TOKEN) or msg == STOP_USER_MESSAGE
|
||||||
|
except Exception:
|
||||||
|
_log.exception("read stop flag failed batch=%s", batch_id)
|
||||||
|
return False
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
def _force_close_holders(batch_id: str) -> int:
|
||||||
|
bid = str(batch_id or "").strip()
|
||||||
|
with _lock:
|
||||||
|
holders = list(_holders.get(bid) or [])
|
||||||
|
n = 0
|
||||||
|
for holder in holders:
|
||||||
|
try:
|
||||||
|
holder["stop_requested"] = True
|
||||||
|
holder["timed_out"] = True
|
||||||
|
conn = holder.get("conn")
|
||||||
|
if conn is not None:
|
||||||
|
close_netmiko_connection(conn)
|
||||||
|
n += 1
|
||||||
|
except Exception:
|
||||||
|
_log.exception("force-close on stop failed batch=%s", bid)
|
||||||
|
return n
|
||||||
|
|
||||||
|
|
||||||
|
def request_stop_collect(task_id: str) -> dict[str, Any]:
|
||||||
|
"""Cancel queued batches and signal running collect for this task to abort."""
|
||||||
|
tid = str(task_id or "").strip()
|
||||||
|
if not tid:
|
||||||
|
return {"ok": False, "reason": "missing_task_id"}
|
||||||
|
|
||||||
|
db = SessionLocal()
|
||||||
|
cancelled_ids: list[str] = []
|
||||||
|
signaled_ids: list[str] = []
|
||||||
|
closed = 0
|
||||||
|
try:
|
||||||
|
task = db.get(BizStateTask, tid)
|
||||||
|
if not task:
|
||||||
|
return {"ok": False, "reason": "task_not_found", "task_id": tid}
|
||||||
|
|
||||||
|
active = (
|
||||||
|
db.query(BizStateBatch)
|
||||||
|
.filter(
|
||||||
|
BizStateBatch.task_id == tid,
|
||||||
|
BizStateBatch.status.in_(("queued", "running")),
|
||||||
|
)
|
||||||
|
.all()
|
||||||
|
)
|
||||||
|
if not active:
|
||||||
|
# Nothing to stop — clear sticky collect_running if orphaned.
|
||||||
|
if bool(task.collect_running):
|
||||||
|
task.collect_running = False
|
||||||
|
if hasattr(task, "collect_queued_at"):
|
||||||
|
task.collect_queued_at = None
|
||||||
|
task.updated_at = utcnow_naive()
|
||||||
|
db.commit()
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"task_id": tid,
|
||||||
|
"stopped": False,
|
||||||
|
"reason": "not_collecting",
|
||||||
|
"cancelled_batches": [],
|
||||||
|
"signaled_batches": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
now = utcnow_naive()
|
||||||
|
still_running = False
|
||||||
|
for batch in active:
|
||||||
|
bid = str(batch.id)
|
||||||
|
mark_stop_requested(bid)
|
||||||
|
if str(batch.status or "") == "queued":
|
||||||
|
batch.status = "cancelled"
|
||||||
|
batch.message = STOP_USER_MESSAGE
|
||||||
|
batch.ended_at = now
|
||||||
|
cancelled_ids.append(bid)
|
||||||
|
else:
|
||||||
|
batch.message = STOP_REQUEST_TOKEN
|
||||||
|
signaled_ids.append(bid)
|
||||||
|
still_running = True
|
||||||
|
closed += _force_close_holders(bid)
|
||||||
|
|
||||||
|
if not still_running:
|
||||||
|
task.collect_running = False
|
||||||
|
if hasattr(task, "collect_queued_at"):
|
||||||
|
task.collect_queued_at = None
|
||||||
|
task.last_collect_ended_at = now
|
||||||
|
task.last_error = STOP_USER_MESSAGE
|
||||||
|
task.updated_at = now
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
_log.info(
|
||||||
|
"stop collect task=%s cancelled=%s signaled=%s closed_conns=%s",
|
||||||
|
tid,
|
||||||
|
cancelled_ids,
|
||||||
|
signaled_ids,
|
||||||
|
closed,
|
||||||
|
)
|
||||||
|
return {
|
||||||
|
"ok": True,
|
||||||
|
"task_id": tid,
|
||||||
|
"stopped": True,
|
||||||
|
"cancelled_batches": cancelled_ids,
|
||||||
|
"signaled_batches": signaled_ids,
|
||||||
|
"closed_connections": closed,
|
||||||
|
}
|
||||||
|
except Exception:
|
||||||
|
_log.exception("request_stop_collect failed task=%s", tid)
|
||||||
|
try:
|
||||||
|
db.rollback()
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return {"ok": False, "reason": "stop_error", "task_id": tid}
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
@ -252,6 +252,19 @@ def api_collect_now(
|
||||||
return {"ok": True, "started": True, "queued": True, "task_id": task_id}
|
return {"ok": True, "started": True, "queued": True, "task_id": task_id}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/tasks/{task_id}/collect/stop")
|
||||||
|
def api_collect_stop(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
|
||||||
|
"""Stop queued or running collect for this task (best-effort mid-command)."""
|
||||||
|
from .biz_state.collect_stop import request_stop_collect
|
||||||
|
|
||||||
|
task = db.get(BizStateTask, task_id)
|
||||||
|
if not task:
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
raise HTTPException(status_code=404, detail="task not found")
|
||||||
|
return request_stop_collect(task_id)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/tasks/{task_id}/batches")
|
@router.get("/tasks/{task_id}/batches")
|
||||||
def api_list_batches(
|
def api_list_batches(
|
||||||
task_id: str, limit: int = 50, db: Session = Depends(get_db)
|
task_id: str, limit: int = 50, db: Session = Depends(get_db)
|
||||||
|
|
|
||||||
|
|
@ -90,7 +90,7 @@ class BizStateBatch(Base):
|
||||||
ne_id: Mapped[str] = mapped_column(String(128), default="", index=True)
|
ne_id: Mapped[str] = mapped_column(String(128), default="", index=True)
|
||||||
ne_name: Mapped[str] = mapped_column(String(256), default="")
|
ne_name: Mapped[str] = mapped_column(String(256), default="")
|
||||||
vendor: Mapped[str] = mapped_column(String(64), default="")
|
vendor: Mapped[str] = mapped_column(String(64), default="")
|
||||||
status: Mapped[str] = mapped_column(String(32), default="running", index=True) # queued|running|success|partial|failed
|
status: Mapped[str] = mapped_column(String(32), default="running", index=True) # queued|running|success|partial|failed|cancelled
|
||||||
command_count: Mapped[int] = mapped_column(Integer, default=0)
|
command_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
row_count: Mapped[int] = mapped_column(Integer, default=0)
|
row_count: Mapped[int] = mapped_column(Integer, default=0)
|
||||||
message: Mapped[str] = mapped_column(String(1024), default="")
|
message: Mapped[str] = mapped_column(String(1024), default="")
|
||||||
|
|
|
||||||
111
tests/test_biz_state_collect_stop.py
Normal file
111
tests/test_biz_state_collect_stop.py
Normal file
|
|
@ -0,0 +1,111 @@
|
||||||
|
"""biz_state collect stop: cancel queued / signal running."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import unittest
|
||||||
|
from unittest.mock import patch
|
||||||
|
|
||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker
|
||||||
|
from sqlalchemy.pool import StaticPool
|
||||||
|
|
||||||
|
from netx_api.biz_state import collect_stop as stop_mod
|
||||||
|
from netx_api.db import Base
|
||||||
|
from netx_api.models import BizStateBatch, BizStateTask
|
||||||
|
|
||||||
|
|
||||||
|
class BizStateCollectStopTests(unittest.TestCase):
|
||||||
|
def setUp(self) -> None:
|
||||||
|
engine = create_engine(
|
||||||
|
"sqlite+pysqlite:///:memory:",
|
||||||
|
future=True,
|
||||||
|
connect_args={"check_same_thread": False},
|
||||||
|
poolclass=StaticPool,
|
||||||
|
)
|
||||||
|
TestingSession = sessionmaker(
|
||||||
|
bind=engine, autoflush=False, autocommit=False, expire_on_commit=False
|
||||||
|
)
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
self.Session = TestingSession
|
||||||
|
self.db = TestingSession()
|
||||||
|
self._session_patch = patch.object(stop_mod, "SessionLocal", TestingSession)
|
||||||
|
self._session_patch.start()
|
||||||
|
stop_mod._stop_batches.clear()
|
||||||
|
stop_mod._holders.clear()
|
||||||
|
stop_mod._db_cache.clear()
|
||||||
|
|
||||||
|
self.task = BizStateTask(
|
||||||
|
id="t-stop",
|
||||||
|
source="managed",
|
||||||
|
ne_id="ne-1",
|
||||||
|
ne_name="NE1",
|
||||||
|
status="running",
|
||||||
|
collect_running=True,
|
||||||
|
)
|
||||||
|
self.db.add(self.task)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
def tearDown(self) -> None:
|
||||||
|
self._session_patch.stop()
|
||||||
|
self.db.close()
|
||||||
|
|
||||||
|
def test_stop_cancels_queued_batch(self) -> None:
|
||||||
|
batch = BizStateBatch(
|
||||||
|
id="b-q",
|
||||||
|
task_id=self.task.id,
|
||||||
|
status="queued",
|
||||||
|
message="queued_manual",
|
||||||
|
)
|
||||||
|
self.db.add(batch)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
out = stop_mod.request_stop_collect(self.task.id)
|
||||||
|
self.assertTrue(out.get("ok"))
|
||||||
|
self.assertTrue(out.get("stopped"))
|
||||||
|
self.assertEqual(out.get("cancelled_batches"), ["b-q"])
|
||||||
|
self.assertEqual(out.get("signaled_batches"), [])
|
||||||
|
|
||||||
|
self.db.expire_all()
|
||||||
|
b = self.db.get(BizStateBatch, "b-q")
|
||||||
|
t = self.db.get(BizStateTask, self.task.id)
|
||||||
|
assert b is not None and t is not None
|
||||||
|
self.assertEqual(b.status, "cancelled")
|
||||||
|
self.assertEqual(b.message, stop_mod.STOP_USER_MESSAGE)
|
||||||
|
self.assertFalse(t.collect_running)
|
||||||
|
|
||||||
|
def test_stop_signals_running_batch(self) -> None:
|
||||||
|
batch = BizStateBatch(
|
||||||
|
id="b-r",
|
||||||
|
task_id=self.task.id,
|
||||||
|
status="running",
|
||||||
|
message="",
|
||||||
|
)
|
||||||
|
self.db.add(batch)
|
||||||
|
self.db.commit()
|
||||||
|
|
||||||
|
out = stop_mod.request_stop_collect(self.task.id)
|
||||||
|
self.assertTrue(out.get("ok"))
|
||||||
|
self.assertTrue(out.get("stopped"))
|
||||||
|
self.assertEqual(out.get("signaled_batches"), ["b-r"])
|
||||||
|
self.assertTrue(stop_mod.is_stop_requested("b-r"))
|
||||||
|
|
||||||
|
self.db.expire_all()
|
||||||
|
b = self.db.get(BizStateBatch, "b-r")
|
||||||
|
t = self.db.get(BizStateTask, self.task.id)
|
||||||
|
assert b is not None and t is not None
|
||||||
|
self.assertEqual(b.status, "running")
|
||||||
|
self.assertEqual(b.message, stop_mod.STOP_REQUEST_TOKEN)
|
||||||
|
# Still running until worker finishes.
|
||||||
|
self.assertTrue(t.collect_running)
|
||||||
|
|
||||||
|
def test_stop_idle(self) -> None:
|
||||||
|
self.task.collect_running = False
|
||||||
|
self.db.commit()
|
||||||
|
out = stop_mod.request_stop_collect(self.task.id)
|
||||||
|
self.assertTrue(out.get("ok"))
|
||||||
|
self.assertFalse(out.get("stopped"))
|
||||||
|
self.assertEqual(out.get("reason"), "not_collecting")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
unittest.main()
|
||||||
|
|
@ -195,6 +195,9 @@ const en = {
|
||||||
scheduleOff: "Manual",
|
scheduleOff: "Manual",
|
||||||
scheduleHint: "When checked, collect on interval; otherwise only Collect now.",
|
scheduleHint: "When checked, collect on interval; otherwise only Collect now.",
|
||||||
collectNow: "Collect now",
|
collectNow: "Collect now",
|
||||||
|
stopCollect: "Stop collect",
|
||||||
|
stopCollectOk: "Stop collect requested",
|
||||||
|
stopCollectIdle: "No collect in progress",
|
||||||
delete: "Delete",
|
delete: "Delete",
|
||||||
confirmDelete: "Delete this task and all batches?",
|
confirmDelete: "Delete this task and all batches?",
|
||||||
deleted: "Deleted",
|
deleted: "Deleted",
|
||||||
|
|
@ -235,6 +238,11 @@ const en = {
|
||||||
scheduleSaved: "Schedule saved",
|
scheduleSaved: "Schedule saved",
|
||||||
colInterval: "Interval",
|
colInterval: "Interval",
|
||||||
profiles: "Monitor items",
|
profiles: "Monitor items",
|
||||||
|
colLane: "Collect lane",
|
||||||
|
laneLight: "Light",
|
||||||
|
laneHeavy: "Heavy",
|
||||||
|
laneLightHint: "Shared SSH with most commands; ~10 min lane cap by default",
|
||||||
|
laneHeavyHint: "Dedicated SSH + longer timeouts; ~40 min lane cap by default",
|
||||||
enable: "On",
|
enable: "On",
|
||||||
command: "Command template",
|
command: "Command template",
|
||||||
colAux: "Aux commands",
|
colAux: "Aux commands",
|
||||||
|
|
|
||||||
|
|
@ -195,6 +195,9 @@ const zh = {
|
||||||
scheduleOff: "手动",
|
scheduleOff: "手动",
|
||||||
scheduleHint: "勾选后按周期自动采集;不勾选则仅支持「立即采集」。",
|
scheduleHint: "勾选后按周期自动采集;不勾选则仅支持「立即采集」。",
|
||||||
collectNow: "立即采集",
|
collectNow: "立即采集",
|
||||||
|
stopCollect: "停止采集",
|
||||||
|
stopCollectOk: "已请求停止采集",
|
||||||
|
stopCollectIdle: "当前没有进行中的采集",
|
||||||
delete: "删除",
|
delete: "删除",
|
||||||
confirmDelete: "删除该业务监控任务及所有批次?",
|
confirmDelete: "删除该业务监控任务及所有批次?",
|
||||||
deleted: "已删除",
|
deleted: "已删除",
|
||||||
|
|
@ -235,6 +238,11 @@ const zh = {
|
||||||
scheduleSaved: "周期配置已保存",
|
scheduleSaved: "周期配置已保存",
|
||||||
colInterval: "周期",
|
colInterval: "周期",
|
||||||
profiles: "监控项",
|
profiles: "监控项",
|
||||||
|
colLane: "采集车道",
|
||||||
|
laneLight: "轻车道",
|
||||||
|
laneHeavy: "重车道",
|
||||||
|
laneLightHint: "与多数命令共用 SSH;整轮默认约 10 分钟上限",
|
||||||
|
laneHeavyHint: "独立 SSH + 更长超时;整轮默认约 40 分钟上限",
|
||||||
enable: "启用",
|
enable: "启用",
|
||||||
command: "命令模板",
|
command: "命令模板",
|
||||||
colAux: "辅命令",
|
colAux: "辅命令",
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,7 @@ import {
|
||||||
formatErr,
|
formatErr,
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
import { formatSystemTime } from "../../utils/time";
|
import { formatSystemTime } from "../../utils/time";
|
||||||
|
import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache";
|
||||||
import { jobChipColor, NmStatusChip } from "./nmChips";
|
import { jobChipColor, NmStatusChip } from "./nmChips";
|
||||||
|
|
||||||
type PageTab = "templates" | "jobs";
|
type PageTab = "templates" | "jobs";
|
||||||
|
|
@ -846,19 +847,36 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
const [navCollapsed, setNavCollapsed] = useState(false);
|
const [navCollapsed, setNavCollapsed] = useState(false);
|
||||||
const tplImportRef = useRef<HTMLInputElement | null>(null);
|
const tplImportRef = useRef<HTMLInputElement | null>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async (opts?: { force?: boolean }) => {
|
||||||
const [taskRes, tpl, maps, j, met] = await Promise.all([
|
type Bundle = {
|
||||||
bizStateListTasks(),
|
taskRes: Awaited<ReturnType<typeof bizStateListTasks>>;
|
||||||
bizCompareListTemplates(),
|
tpl: Awaited<ReturnType<typeof bizCompareListTemplates>>;
|
||||||
bizCompareListMappings(),
|
maps: Awaited<ReturnType<typeof bizCompareListMappings>>;
|
||||||
bizCompareListJobs(),
|
j: Awaited<ReturnType<typeof bizCompareListJobs>>;
|
||||||
bizCompareListMetrics(),
|
met: Awaited<ReturnType<typeof bizCompareListMetrics>>;
|
||||||
]);
|
};
|
||||||
setTasks((taskRes.items || []) as TaskOpt[]);
|
const fetchBundle = async (): Promise<Bundle> => {
|
||||||
setTemplates((tpl.items || []) as Template[]);
|
const [taskRes, tpl, maps, j, met] = await Promise.all([
|
||||||
setMappings((maps.items || []) as Mapping[]);
|
bizStateListTasks(),
|
||||||
setJobs((j.items || []) as Job[]);
|
bizCompareListTemplates(),
|
||||||
setMetrics((met.items || []) as MetricSchema[]);
|
bizCompareListMappings(),
|
||||||
|
bizCompareListJobs(),
|
||||||
|
bizCompareListMetrics(),
|
||||||
|
]);
|
||||||
|
return { taskRes, tpl, maps, j, met };
|
||||||
|
};
|
||||||
|
const apply = (b: Bundle) => {
|
||||||
|
setTasks((b.taskRes.items || []) as TaskOpt[]);
|
||||||
|
setTemplates((b.tpl.items || []) as Template[]);
|
||||||
|
setMappings((b.maps.items || []) as Mapping[]);
|
||||||
|
setJobs((b.j.items || []) as Job[]);
|
||||||
|
setMetrics((b.met.items || []) as MetricSchema[]);
|
||||||
|
};
|
||||||
|
if (opts?.force) {
|
||||||
|
apply(await cutoverCachedGet("bizCompare:lists", fetchBundle, { force: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apply(await cutoverCachedGetSWR("bizCompare:lists", fetchBundle, apply));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1457,7 +1475,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
else await bizCompareCreateTemplate(body);
|
else await bizCompareCreateTemplate(body);
|
||||||
showOk(t("bizCompare.templateSaved"));
|
showOk(t("bizCompare.templateSaved"));
|
||||||
setTplOpen(false);
|
setTplOpen(false);
|
||||||
await refresh();
|
invalidateCutoverCache("bizMonitor:");
|
||||||
|
invalidateCutoverCache("bizMigration:");
|
||||||
|
await refresh({ force: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1471,7 +1491,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
try {
|
try {
|
||||||
await bizCompareDeleteTemplate(id);
|
await bizCompareDeleteTemplate(id);
|
||||||
showOk(t("bizCompare.templateDeleted"));
|
showOk(t("bizCompare.templateDeleted"));
|
||||||
await refresh();
|
invalidateCutoverCache("bizMonitor:");
|
||||||
|
invalidateCutoverCache("bizMigration:");
|
||||||
|
await refresh({ force: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1519,7 +1541,9 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
showOk(t("bizCompare.templateImported"));
|
showOk(t("bizCompare.templateImported"));
|
||||||
await refresh();
|
invalidateCutoverCache("bizMonitor:");
|
||||||
|
invalidateCutoverCache("bizMigration:");
|
||||||
|
await refresh({ force: true });
|
||||||
if (pageMode !== "jobs") setPageTab("templates");
|
if (pageMode !== "jobs") setPageTab("templates");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
|
|
@ -1536,7 +1560,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
await bizCompareDeleteJob(id);
|
await bizCompareDeleteJob(id);
|
||||||
if (jobId === id) closeJob();
|
if (jobId === id) closeJob();
|
||||||
showOk(t("bizCompare.jobDeleted"));
|
showOk(t("bizCompare.jobDeleted"));
|
||||||
await refresh();
|
await refresh({ force: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1577,7 +1601,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
setMappingId(String(m.id));
|
setMappingId(String(m.id));
|
||||||
}
|
}
|
||||||
showOk(t("bizCompare.mappingSaved"));
|
showOk(t("bizCompare.mappingSaved"));
|
||||||
await refresh();
|
await refresh({ force: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1712,7 +1736,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
const j = await bizCompareCreateJob(jobConfigBody());
|
const j = await bizCompareCreateJob(jobConfigBody());
|
||||||
showOk(t("bizCompare.created"));
|
showOk(t("bizCompare.created"));
|
||||||
closeCreateJob();
|
closeCreateJob();
|
||||||
await refresh();
|
await refresh({ force: true });
|
||||||
await openJob(String(j.id));
|
await openJob(String(j.id));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
|
|
@ -1764,7 +1788,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
try {
|
try {
|
||||||
await bizCompareUpdateJob(jobId, jobConfigBody());
|
await bizCompareUpdateJob(jobId, jobConfigBody());
|
||||||
showOk(t("bizCompare.jobSaved"));
|
showOk(t("bizCompare.jobSaved"));
|
||||||
await refresh();
|
await refresh({ force: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1787,7 +1811,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
showOk(t("bizCompare.ran"));
|
showOk(t("bizCompare.ran"));
|
||||||
const r = await bizCompareListRuns(jobId);
|
const r = await bizCompareListRuns(jobId);
|
||||||
setRuns(r.items || []);
|
setRuns(r.items || []);
|
||||||
await refresh();
|
await refresh({ force: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -40,6 +40,7 @@ import type { CliTargetItem } from "../../types";
|
||||||
import { writeClipboardText } from "../../utils/clipboard";
|
import { writeClipboardText } from "../../utils/clipboard";
|
||||||
import { pageCount } from "../../utils/display";
|
import { pageCount } from "../../utils/display";
|
||||||
import { formatSystemTime, localDatetimeInputToUtcIso, utcIsoToLocalDatetimeInput } from "../../utils/time";
|
import { formatSystemTime, localDatetimeInputToUtcIso, utcIsoToLocalDatetimeInput } from "../../utils/time";
|
||||||
|
import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache";
|
||||||
import { jobChipColor, NmStatusChip, sourceChipColor } from "./nmChips";
|
import { jobChipColor, NmStatusChip, sourceChipColor } from "./nmChips";
|
||||||
|
|
||||||
type NeSourceFilter = "all" | "managed" | "ume";
|
type NeSourceFilter = "all" | "managed" | "ume";
|
||||||
|
|
@ -520,12 +521,17 @@ export function BizMigrationPage() {
|
||||||
}, [expectSheets, expectMetricId, portFilter]);
|
}, [expectSheets, expectMetricId, portFilter]);
|
||||||
|
|
||||||
const reloadProjects = useCallback(async () => {
|
const reloadProjects = useCallback(async () => {
|
||||||
const res = await bizMigrationListProjects();
|
const res = await cutoverCachedGet("bizMigration:projects", () => bizMigrationListProjects(), {
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
setProjects((res.items || []) as Project[]);
|
setProjects((res.items || []) as Project[]);
|
||||||
|
invalidateCutoverCache("bizMigration:bootstrap");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const reloadMappings = useCallback(async () => {
|
const reloadMappings = useCallback(async () => {
|
||||||
const mp = await bizCompareListMappings();
|
const mp = await cutoverCachedGet("bizMigration:mappings", () => bizCompareListMappings(), {
|
||||||
|
force: true,
|
||||||
|
});
|
||||||
setMappings(
|
setMappings(
|
||||||
((mp.items || []) as Record<string, unknown>[]).map((x) => ({
|
((mp.items || []) as Record<string, unknown>[]).map((x) => ({
|
||||||
id: String(x.id || ""),
|
id: String(x.id || ""),
|
||||||
|
|
@ -538,6 +544,7 @@ export function BizMigrationPage() {
|
||||||
: [],
|
: [],
|
||||||
})),
|
})),
|
||||||
);
|
);
|
||||||
|
invalidateCutoverCache("bizMigration:bootstrap");
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const parseMapRows = () => {
|
const parseMapRows = () => {
|
||||||
|
|
@ -626,45 +633,56 @@ export function BizMigrationPage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
const [pr, mp, mt] = await Promise.all([
|
type Bundle = {
|
||||||
bizMigrationListProjects(),
|
pr: Awaited<ReturnType<typeof bizMigrationListProjects>>;
|
||||||
bizCompareListMappings(),
|
mp: Awaited<ReturnType<typeof bizCompareListMappings>>;
|
||||||
bizMonitorListTemplates(),
|
mt: Awaited<ReturnType<typeof bizMonitorListTemplates>>;
|
||||||
]);
|
};
|
||||||
setProjects((pr.items || []) as Project[]);
|
const fetchBundle = async (): Promise<Bundle> => {
|
||||||
setMappings(
|
const [pr, mp, mt] = await Promise.all([
|
||||||
((mp.items || []) as Record<string, unknown>[]).map((x) => ({
|
bizMigrationListProjects(),
|
||||||
|
bizCompareListMappings(),
|
||||||
|
bizMonitorListTemplates(),
|
||||||
|
]);
|
||||||
|
return { pr, mp, mt };
|
||||||
|
};
|
||||||
|
const apply = (b: Bundle) => {
|
||||||
|
setProjects((b.pr.items || []) as Project[]);
|
||||||
|
setMappings(
|
||||||
|
((b.mp.items || []) as Record<string, unknown>[]).map((x) => ({
|
||||||
|
id: String(x.id || ""),
|
||||||
|
name: String(x.name || x.id || ""),
|
||||||
|
rows: Array.isArray(x.rows)
|
||||||
|
? (x.rows as Array<{ before_if?: string; after_if?: string }>).map((r) => ({
|
||||||
|
before_if: String(r.before_if || ""),
|
||||||
|
after_if: String(r.after_if || ""),
|
||||||
|
}))
|
||||||
|
: [],
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
const mts = ((b.mt.items || []) as Record<string, unknown>[]).map((x) => ({
|
||||||
id: String(x.id || ""),
|
id: String(x.id || ""),
|
||||||
name: String(x.name || x.id || ""),
|
name: String(x.name || x.id || ""),
|
||||||
rows: Array.isArray(x.rows)
|
compare_template_id: String(x.compare_template_id || ""),
|
||||||
? (x.rows as Array<{ before_if?: string; after_if?: string }>).map((r) => ({
|
compare_template_name: String(x.compare_template_name || ""),
|
||||||
before_if: String(r.before_if || ""),
|
collect_metric_ids: Array.isArray(x.collect_metric_ids)
|
||||||
after_if: String(r.after_if || ""),
|
? (x.collect_metric_ids as string[])
|
||||||
}))
|
|
||||||
: [],
|
: [],
|
||||||
})),
|
collect_metric_ids_effective: Array.isArray(x.collect_metric_ids_effective)
|
||||||
);
|
? (x.collect_metric_ids_effective as string[])
|
||||||
const mts = ((mt.items || []) as Record<string, unknown>[]).map((x) => ({
|
: [],
|
||||||
id: String(x.id || ""),
|
}));
|
||||||
name: String(x.name || x.id || ""),
|
setMonitorTpls(mts);
|
||||||
compare_template_id: String(x.compare_template_id || ""),
|
if (!createMonitorTplId && mts.length) {
|
||||||
compare_template_name: String(x.compare_template_name || ""),
|
const preferred =
|
||||||
collect_metric_ids: Array.isArray(x.collect_metric_ids)
|
mts.find((x) => x.name === "默认割接监控") ||
|
||||||
? (x.collect_metric_ids as string[])
|
mts.find((x) => /默认|default|状态|status/i.test(x.name)) ||
|
||||||
: [],
|
mts[0];
|
||||||
collect_metric_ids_effective: Array.isArray(x.collect_metric_ids_effective)
|
setCreateMonitorTplId(preferred.id);
|
||||||
? (x.collect_metric_ids_effective as string[])
|
setCreateCollectMetricIds(monitorTplMetrics(preferred));
|
||||||
: [],
|
}
|
||||||
}));
|
};
|
||||||
setMonitorTpls(mts);
|
apply(await cutoverCachedGetSWR("bizMigration:bootstrap", fetchBundle, apply));
|
||||||
if (!createMonitorTplId && mts.length) {
|
|
||||||
const preferred =
|
|
||||||
mts.find((x) => x.name === "默认割接监控") ||
|
|
||||||
mts.find((x) => /默认|default|状态|status/i.test(x.name)) ||
|
|
||||||
mts[0];
|
|
||||||
setCreateMonitorTplId(preferred.id);
|
|
||||||
setCreateCollectMetricIds(monitorTplMetrics(preferred));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import {
|
||||||
bizMonitorUpdateTemplate,
|
bizMonitorUpdateTemplate,
|
||||||
formatErr,
|
formatErr,
|
||||||
} from "../../services/api";
|
} from "../../services/api";
|
||||||
|
import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache";
|
||||||
|
|
||||||
type MetricField = { name: string; display_name?: string };
|
type MetricField = { name: string; display_name?: string };
|
||||||
type MetricSchema = { metric_id: string; fields: MetricField[] };
|
type MetricSchema = { metric_id: string; fields: MetricField[] };
|
||||||
|
|
@ -828,29 +829,44 @@ export function BizMonitorTemplatesPage() {
|
||||||
const [overridesText, setOverridesText] = useState("[]");
|
const [overridesText, setOverridesText] = useState("[]");
|
||||||
const tplImportRef = useRef<HTMLInputElement>(null);
|
const tplImportRef = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async (opts?: { force?: boolean }) => {
|
||||||
const [mon, cmp, metrics] = await Promise.all([
|
type Bundle = {
|
||||||
bizMonitorListTemplates(),
|
mon: Awaited<ReturnType<typeof bizMonitorListTemplates>>;
|
||||||
bizCompareListTemplates(),
|
cmp: Awaited<ReturnType<typeof bizCompareListTemplates>>;
|
||||||
bizCompareListMetrics(),
|
metrics: Awaited<ReturnType<typeof bizCompareListMetrics>>;
|
||||||
]);
|
};
|
||||||
setItems((mon.items || []) as MonitorTpl[]);
|
const fetchBundle = async (): Promise<Bundle> => {
|
||||||
setCompareTpls(
|
const [mon, cmp, metrics] = await Promise.all([
|
||||||
((cmp.items || []) as Record<string, unknown>[]).map((x) => ({
|
bizMonitorListTemplates(),
|
||||||
id: String(x.id || ""),
|
bizCompareListTemplates(),
|
||||||
name: String(x.name || x.id || ""),
|
bizCompareListMetrics(),
|
||||||
metrics: Array.isArray(x.metrics) ? (x.metrics as CompareSheet[]) : [],
|
]);
|
||||||
})),
|
return { mon, cmp, metrics };
|
||||||
);
|
};
|
||||||
setMetricSchemas(
|
const apply = (b: Bundle) => {
|
||||||
((metrics.items || []) as MetricSchema[]).map((m) => ({
|
setItems((b.mon.items || []) as MonitorTpl[]);
|
||||||
metric_id: m.metric_id,
|
setCompareTpls(
|
||||||
fields: (m.fields || []).map((f) => ({
|
((b.cmp.items || []) as Record<string, unknown>[]).map((x) => ({
|
||||||
name: f.name,
|
id: String(x.id || ""),
|
||||||
display_name: f.display_name,
|
name: String(x.name || x.id || ""),
|
||||||
|
metrics: Array.isArray(x.metrics) ? (x.metrics as CompareSheet[]) : [],
|
||||||
})),
|
})),
|
||||||
})),
|
);
|
||||||
);
|
setMetricSchemas(
|
||||||
|
((b.metrics.items || []) as MetricSchema[]).map((m) => ({
|
||||||
|
metric_id: m.metric_id,
|
||||||
|
fields: (m.fields || []).map((f) => ({
|
||||||
|
name: f.name,
|
||||||
|
display_name: f.display_name,
|
||||||
|
})),
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
if (opts?.force) {
|
||||||
|
apply(await cutoverCachedGet("bizMonitor:lists", fetchBundle, { force: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
apply(await cutoverCachedGetSWR("bizMonitor:lists", fetchBundle, apply));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -1016,7 +1032,8 @@ export function BizMonitorTemplatesPage() {
|
||||||
showOk(t("bizMonitorTpl.created"));
|
showOk(t("bizMonitorTpl.created"));
|
||||||
}
|
}
|
||||||
closeEdit();
|
closeEdit();
|
||||||
await refresh();
|
invalidateCutoverCache("bizMigration:");
|
||||||
|
await refresh({ force: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1030,7 +1047,8 @@ export function BizMonitorTemplatesPage() {
|
||||||
try {
|
try {
|
||||||
await bizMonitorDeleteTemplate(id);
|
await bizMonitorDeleteTemplate(id);
|
||||||
showOk(t("bizMonitorTpl.deleted"));
|
showOk(t("bizMonitorTpl.deleted"));
|
||||||
await refresh();
|
invalidateCutoverCache("bizMigration:");
|
||||||
|
await refresh({ force: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
@ -1083,7 +1101,8 @@ export function BizMonitorTemplatesPage() {
|
||||||
sheet_overrides: body.sheet_overrides,
|
sheet_overrides: body.sheet_overrides,
|
||||||
});
|
});
|
||||||
showOk(t("bizMonitorTpl.templateImported"));
|
showOk(t("bizMonitorTpl.templateImported"));
|
||||||
await refresh();
|
invalidateCutoverCache("bizMigration:");
|
||||||
|
await refresh({ force: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import { useI18n } from "../../i18n";
|
||||||
import {
|
import {
|
||||||
bizStateBulkDeleteBatches,
|
bizStateBulkDeleteBatches,
|
||||||
bizStateCollectNow,
|
bizStateCollectNow,
|
||||||
|
bizStateCollectStop,
|
||||||
bizStateCreateTask,
|
bizStateCreateTask,
|
||||||
bizStateDeleteBatch,
|
bizStateDeleteBatch,
|
||||||
bizStateDeleteTask,
|
bizStateDeleteTask,
|
||||||
|
|
@ -37,6 +38,7 @@ import type { CliTargetItem } from "../../types";
|
||||||
import { pageCount } from "../../utils/display";
|
import { pageCount } from "../../utils/display";
|
||||||
import { writeClipboardText } from "../../utils/clipboard";
|
import { writeClipboardText } from "../../utils/clipboard";
|
||||||
import { formatSystemTime } from "../../utils/time";
|
import { formatSystemTime } from "../../utils/time";
|
||||||
|
import { cutoverCachedGet, cutoverCachedGetSWR, invalidateCutoverCache } from "./cutoverDataCache";
|
||||||
import { jobChipColor, NmStatusChip, sourceChipColor } from "./nmChips";
|
import { jobChipColor, NmStatusChip, sourceChipColor } from "./nmChips";
|
||||||
|
|
||||||
type TaskRow = {
|
type TaskRow = {
|
||||||
|
|
@ -68,6 +70,8 @@ type Profile = {
|
||||||
description: string;
|
description: string;
|
||||||
metric_id: string;
|
metric_id: string;
|
||||||
kind?: string;
|
kind?: string;
|
||||||
|
/** light | heavy — collect dual-lane */
|
||||||
|
collect_lane?: string;
|
||||||
placeholders?: Placeholder[];
|
placeholders?: Placeholder[];
|
||||||
aux_commands?: Array<{
|
aux_commands?: Array<{
|
||||||
key: string;
|
key: string;
|
||||||
|
|
@ -305,7 +309,8 @@ export function BizStatePage() {
|
||||||
const refreshTasks = useCallback(async () => {
|
const refreshTasks = useCallback(async () => {
|
||||||
const purpose =
|
const purpose =
|
||||||
purposeFilter === "all" ? "" : purposeFilter === "portrait" ? "portrait" : "cutover_hf";
|
purposeFilter === "all" ? "" : purposeFilter === "portrait" ? "portrait" : "cutover_hf";
|
||||||
const res = await bizStateListTasks(purpose);
|
const key = `bizState:tasks:${purpose || "all"}`;
|
||||||
|
const res = await cutoverCachedGet(key, () => bizStateListTasks(purpose), { force: true });
|
||||||
const items = (res.items || []) as TaskRow[];
|
const items = (res.items || []) as TaskRow[];
|
||||||
setTasks(items);
|
setTasks(items);
|
||||||
return items;
|
return items;
|
||||||
|
|
@ -333,13 +338,19 @@ export function BizStatePage() {
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void (async () => {
|
void (async () => {
|
||||||
try {
|
try {
|
||||||
await refreshTasks();
|
const purpose =
|
||||||
|
purposeFilter === "all" ? "" : purposeFilter === "portrait" ? "portrait" : "cutover_hf";
|
||||||
|
const key = `bizState:tasks:${purpose || "all"}`;
|
||||||
|
const apply = (res: Awaited<ReturnType<typeof bizStateListTasks>>) => {
|
||||||
|
setTasks((res.items || []) as TaskRow[]);
|
||||||
|
};
|
||||||
|
apply(await cutoverCachedGetSWR(key, () => bizStateListTasks(purpose), apply));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
}
|
}
|
||||||
})();
|
})();
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- refresh when purpose filter / refreshTasks changes
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- refresh when purpose filter changes
|
||||||
}, [refreshTasks]);
|
}, [purposeFilter]);
|
||||||
|
|
||||||
// Progress poll while any collect is running (list chips and/or open task).
|
// Progress poll while any collect is running (list chips and/or open task).
|
||||||
// Does NOT block navigation; cleans up on unmount / when nothing is collecting.
|
// Does NOT block navigation; cleans up on unmount / when nothing is collecting.
|
||||||
|
|
@ -625,6 +636,7 @@ export function BizStatePage() {
|
||||||
});
|
});
|
||||||
showOk(t("bizState.created"));
|
showOk(t("bizState.created"));
|
||||||
closeCreate();
|
closeCreate();
|
||||||
|
invalidateCutoverCache("bizCompare:");
|
||||||
await refreshTasks();
|
await refreshTasks();
|
||||||
await openTask(String(task.id), "profiles");
|
await openTask(String(task.id), "profiles");
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
@ -815,6 +827,35 @@ export function BizStatePage() {
|
||||||
await collectNowForTask(taskId, true);
|
await collectNowForTask(taskId, true);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopCollectForTask = async (id: string) => {
|
||||||
|
setBusy(true);
|
||||||
|
try {
|
||||||
|
const out = await bizStateCollectStop(id);
|
||||||
|
if (out.stopped) {
|
||||||
|
showOk(t("bizState.stopCollectOk"));
|
||||||
|
} else {
|
||||||
|
showOk(t("bizState.stopCollectIdle"));
|
||||||
|
}
|
||||||
|
setTaskCollecting(id, false);
|
||||||
|
try {
|
||||||
|
await refreshTasks();
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
if (taskId === id) {
|
||||||
|
try {
|
||||||
|
await refreshTaskProgress(id);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showError(formatErr(e));
|
||||||
|
} finally {
|
||||||
|
setBusy(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const exportTaskCommands = async () => {
|
const exportTaskCommands = async () => {
|
||||||
if (!taskId) return;
|
if (!taskId) return;
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|
@ -835,6 +876,7 @@ export function BizStatePage() {
|
||||||
await bizStateDeleteTask(id);
|
await bizStateDeleteTask(id);
|
||||||
showOk(t("bizState.deleted"));
|
showOk(t("bizState.deleted"));
|
||||||
if (taskId === id) closeTask();
|
if (taskId === id) closeTask();
|
||||||
|
invalidateCutoverCache("bizCompare:");
|
||||||
await refreshTasks();
|
await refreshTasks();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
showError(formatErr(e));
|
showError(formatErr(e));
|
||||||
|
|
@ -1225,6 +1267,16 @@ export function BizStatePage() {
|
||||||
>
|
>
|
||||||
{t("bizState.collectNow")}
|
{t("bizState.collectNow")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{row.collect_running || collectingIds[row.id] ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
isDisabled={busy}
|
||||||
|
onPress={() => void stopCollectForTask(row.id)}
|
||||||
|
>
|
||||||
|
{t("bizState.stopCollect")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="danger"
|
variant="danger"
|
||||||
|
|
@ -1502,6 +1554,7 @@ export function BizStatePage() {
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t("bizState.enable")}</th>
|
<th>{t("bizState.enable")}</th>
|
||||||
<th>{t("bizState.profiles")}</th>
|
<th>{t("bizState.profiles")}</th>
|
||||||
|
<th>{t("bizState.colLane")}</th>
|
||||||
<th>{t("bizState.params")}</th>
|
<th>{t("bizState.params")}</th>
|
||||||
<th>{t("bizState.colActions")}</th>
|
<th>{t("bizState.colActions")}</th>
|
||||||
<th>{t("bizState.command")}</th>
|
<th>{t("bizState.command")}</th>
|
||||||
|
|
@ -1569,6 +1622,17 @@ export function BizStatePage() {
|
||||||
<div className="bs-bind-hint muted">{t("bizState.bindHintRequired")}</div>
|
<div className="bs-bind-hint muted">{t("bizState.bindHintRequired")}</div>
|
||||||
) : null}
|
) : null}
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
{String(prof.collect_lane || "light").toLowerCase() === "heavy" ? (
|
||||||
|
<span title={t("bizState.laneHeavyHint")}>
|
||||||
|
<NmStatusChip color="warning">{t("bizState.laneHeavy")}</NmStatusChip>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span title={t("bizState.laneLightHint")}>
|
||||||
|
<NmStatusChip color="default">{t("bizState.laneLight")}</NmStatusChip>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
<td className="bs-params-cell">
|
<td className="bs-params-cell">
|
||||||
{needsBind ? (
|
{needsBind ? (
|
||||||
<div
|
<div
|
||||||
|
|
@ -1788,6 +1852,16 @@ export function BizStatePage() {
|
||||||
>
|
>
|
||||||
{t("bizState.collectNow")}
|
{t("bizState.collectNow")}
|
||||||
</Button>
|
</Button>
|
||||||
|
{detail?.collect_running || (taskId && collectingIds[taskId]) ? (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="danger"
|
||||||
|
isDisabled={busy || !taskId}
|
||||||
|
onPress={() => void stopCollectForTask(taskId)}
|
||||||
|
>
|
||||||
|
{t("bizState.stopCollect")}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="secondary"
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
||||||
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
import { NavLink, Outlet, useLocation } from "react-router-dom";
|
||||||
import { NETWORK_NAV, type NetworkNavGroupId } from "../../config/networkNav";
|
import { NETWORK_NAV, type NetworkNavGroupId } from "../../config/networkNav";
|
||||||
import { useI18n } from "../../i18n";
|
import { useI18n } from "../../i18n";
|
||||||
|
import { prefetchCutoverPages } from "./cutoverPrefetch";
|
||||||
|
|
||||||
function groupContainsPath(groupId: NetworkNavGroupId, pathname: string): boolean {
|
function groupContainsPath(groupId: NetworkNavGroupId, pathname: string): boolean {
|
||||||
const group = NETWORK_NAV.find((g) => g.id === groupId);
|
const group = NETWORK_NAV.find((g) => g.id === groupId);
|
||||||
|
|
@ -36,6 +37,13 @@ export function NetworkLayout() {
|
||||||
});
|
});
|
||||||
}, [pathname]);
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Warm cutover page chunks as soon as that nav group is open / in use.
|
||||||
|
useEffect(() => {
|
||||||
|
if (pathname.startsWith("/network/cutover") || expandedKeys.has("cutoverCompare")) {
|
||||||
|
prefetchCutoverPages();
|
||||||
|
}
|
||||||
|
}, [pathname, expandedKeys]);
|
||||||
|
|
||||||
const activeGroupIds = useMemo(() => {
|
const activeGroupIds = useMemo(() => {
|
||||||
const ids = new Set<NetworkNavGroupId>();
|
const ids = new Set<NetworkNavGroupId>();
|
||||||
for (const g of NETWORK_NAV) {
|
for (const g of NETWORK_NAV) {
|
||||||
|
|
@ -54,7 +62,12 @@ export function NetworkLayout() {
|
||||||
hideSeparator
|
hideSeparator
|
||||||
allowsMultipleExpanded
|
allowsMultipleExpanded
|
||||||
expandedKeys={expandedKeys}
|
expandedKeys={expandedKeys}
|
||||||
onExpandedChange={setExpandedKeys}
|
onExpandedChange={(keys) => {
|
||||||
|
setExpandedKeys(keys as Set<Key>);
|
||||||
|
if ((keys as Set<Key>).has("cutoverCompare")) {
|
||||||
|
prefetchCutoverPages();
|
||||||
|
}
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{NETWORK_NAV.map((group) => {
|
{NETWORK_NAV.map((group) => {
|
||||||
const activeGroup = activeGroupIds.has(group.id);
|
const activeGroup = activeGroupIds.has(group.id);
|
||||||
|
|
@ -65,7 +78,12 @@ export function NetworkLayout() {
|
||||||
className={`network-nav__group${activeGroup ? " is-active-group" : ""}`}
|
className={`network-nav__group${activeGroup ? " is-active-group" : ""}`}
|
||||||
>
|
>
|
||||||
<Accordion.Heading>
|
<Accordion.Heading>
|
||||||
<Accordion.Trigger className="network-nav__group-toggle">
|
<Accordion.Trigger
|
||||||
|
className="network-nav__group-toggle"
|
||||||
|
onHoverStart={
|
||||||
|
group.id === "cutoverCompare" ? () => prefetchCutoverPages() : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
<span className="network-nav__group-label">{t(group.labelKey)}</span>
|
<span className="network-nav__group-label">{t(group.labelKey)}</span>
|
||||||
<Accordion.Indicator className="network-nav__chevron" />
|
<Accordion.Indicator className="network-nav__chevron" />
|
||||||
</Accordion.Trigger>
|
</Accordion.Trigger>
|
||||||
|
|
@ -84,6 +102,11 @@ export function NetworkLayout() {
|
||||||
item.path === "/network/devices" ||
|
item.path === "/network/devices" ||
|
||||||
item.path === "/network/tasks/port-traffic"
|
item.path === "/network/tasks/port-traffic"
|
||||||
}
|
}
|
||||||
|
onMouseEnter={
|
||||||
|
group.id === "cutoverCompare"
|
||||||
|
? () => prefetchCutoverPages()
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{t(item.labelKey)}
|
{t(item.labelKey)}
|
||||||
</NavLink>
|
</NavLink>
|
||||||
|
|
|
||||||
60
web/src/pages/network/cutoverDataCache.ts
Normal file
60
web/src/pages/network/cutoverDataCache.ts
Normal file
|
|
@ -0,0 +1,60 @@
|
||||||
|
/**
|
||||||
|
* Short-lived cache for cutover list APIs.
|
||||||
|
* Switching biz-state / compare / migration / templates remounts each page;
|
||||||
|
* without cache every click re-waits on Promise.all network round-trips.
|
||||||
|
*/
|
||||||
|
|
||||||
|
type Entry = { at: number; data: unknown };
|
||||||
|
|
||||||
|
const store = new Map<string, Entry>();
|
||||||
|
const DEFAULT_TTL_MS = 45_000;
|
||||||
|
|
||||||
|
export async function cutoverCachedGet<T>(
|
||||||
|
key: string,
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
opts?: { force?: boolean; ttlMs?: number },
|
||||||
|
): Promise<T> {
|
||||||
|
const ttl = opts?.ttlMs ?? DEFAULT_TTL_MS;
|
||||||
|
if (!opts?.force) {
|
||||||
|
const hit = store.get(key);
|
||||||
|
if (hit && Date.now() - hit.at < ttl) {
|
||||||
|
return hit.data as T;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const data = await fetcher();
|
||||||
|
store.set(key, { at: Date.now(), data });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Return stale immediately (if any), refresh in background and notify. */
|
||||||
|
export async function cutoverCachedGetSWR<T>(
|
||||||
|
key: string,
|
||||||
|
fetcher: () => Promise<T>,
|
||||||
|
onFresh?: (data: T) => void,
|
||||||
|
): Promise<T> {
|
||||||
|
const hit = store.get(key);
|
||||||
|
if (hit) {
|
||||||
|
void fetcher()
|
||||||
|
.then((data) => {
|
||||||
|
store.set(key, { at: Date.now(), data });
|
||||||
|
onFresh?.(data);
|
||||||
|
})
|
||||||
|
.catch(() => {
|
||||||
|
/* keep stale */
|
||||||
|
});
|
||||||
|
return hit.data as T;
|
||||||
|
}
|
||||||
|
const data = await fetcher();
|
||||||
|
store.set(key, { at: Date.now(), data });
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function invalidateCutoverCache(prefix = ""): void {
|
||||||
|
if (!prefix) {
|
||||||
|
store.clear();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
for (const k of [...store.keys()]) {
|
||||||
|
if (k.startsWith(prefix)) store.delete(k);
|
||||||
|
}
|
||||||
|
}
|
||||||
13
web/src/pages/network/cutoverPrefetch.ts
Normal file
13
web/src/pages/network/cutoverPrefetch.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
/** Prefetch cutover compare route chunks so nav switches don't wait on first download. */
|
||||||
|
|
||||||
|
let started = false;
|
||||||
|
|
||||||
|
export function prefetchCutoverPages(): void {
|
||||||
|
if (started) return;
|
||||||
|
started = true;
|
||||||
|
// Fire-and-forget dynamic imports (same modules as App.tsx lazy()).
|
||||||
|
void import("./BizStatePage");
|
||||||
|
void import("./BizComparePage");
|
||||||
|
void import("./BizMigrationPage");
|
||||||
|
void import("./BizMonitorTemplatesPage");
|
||||||
|
}
|
||||||
|
|
@ -35,7 +35,9 @@ export function jobChipColor(status: string | null | undefined): NmChipColor {
|
||||||
.toLowerCase();
|
.toLowerCase();
|
||||||
if (!s) return "default";
|
if (!s) return "default";
|
||||||
if (s === "running" || s === "pending" || s === "collect") return "success";
|
if (s === "running" || s === "pending" || s === "collect") return "success";
|
||||||
if (s === "paused" || s === "draft" || s === "skipped") return "warning";
|
if (s === "paused" || s === "draft" || s === "skipped" || s === "partial" || s === "cancelled" || s === "queued") {
|
||||||
|
return "warning";
|
||||||
|
}
|
||||||
if (s === "failed" || s === "fail" || s === "error" || s === "stopped") return "danger";
|
if (s === "failed" || s === "fail" || s === "error" || s === "stopped") return "danger";
|
||||||
if (s === "success" || s === "completed" || s === "ok" || s === "done") return "success";
|
if (s === "success" || s === "completed" || s === "ok" || s === "done") return "success";
|
||||||
return "default";
|
return "default";
|
||||||
|
|
|
||||||
|
|
@ -1826,6 +1826,15 @@ export const bizStateDeleteTask = (taskId: string) =>
|
||||||
export const bizStateCollectNow = (taskId: string) =>
|
export const bizStateCollectNow = (taskId: string) =>
|
||||||
apiPost<{ ok: boolean }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/collect`, {});
|
apiPost<{ ok: boolean }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/collect`, {});
|
||||||
|
|
||||||
|
export const bizStateCollectStop = (taskId: string) =>
|
||||||
|
apiPost<{
|
||||||
|
ok: boolean;
|
||||||
|
stopped?: boolean;
|
||||||
|
reason?: string;
|
||||||
|
cancelled_batches?: string[];
|
||||||
|
signaled_batches?: string[];
|
||||||
|
}>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/collect/stop`, {});
|
||||||
|
|
||||||
export const bizStateListBatches = (taskId: string, limit = 50) =>
|
export const bizStateListBatches = (taskId: string, limit = 50) =>
|
||||||
apiGet<{ items: Record<string, unknown>[] }>(
|
apiGet<{ items: Record<string, unknown>[] }>(
|
||||||
`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches?limit=${limit}`,
|
`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/batches?limit=${limit}`,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue