diff --git a/netx_api/biz_migration/monitor_templates.py b/netx_api/biz_migration/monitor_templates.py new file mode 100644 index 0000000..6e82a6c --- /dev/null +++ b/netx_api/biz_migration/monitor_templates.py @@ -0,0 +1,216 @@ +"""CRUD for BizMonitorTemplate (cutover overlay on compare templates).""" + +from __future__ import annotations + +from typing import Any +from uuid import uuid4 + +from fastapi import HTTPException +from sqlalchemy.orm import Session + +from ..models import BizCompareTemplate, BizMonitorTemplate +from ..timeutil import utcnow_naive +from ..biz_state import compare_service as cmp_svc +from .evaluate import PORT_METRIC_ID, PORT_STATUS_FIELDS + + +def _utcnow(): + return utcnow_naive() + + +def _out(row: BizMonitorTemplate, compare_name: str = "") -> dict[str, Any]: + return { + "id": row.id, + "name": row.name, + "compare_template_id": row.compare_template_id or "", + "compare_template_name": compare_name, + "collect_metric_ids": list(row.collect_metric_ids_json or []), + "defaults": dict(row.defaults_json or {}), + "sheet_overrides": list(row.sheet_overrides_json or []), + "note": row.note or "", + "created_at": row.created_at.isoformat() + "Z" if row.created_at else None, + "updated_at": row.updated_at.isoformat() + "Z" if row.updated_at else None, + } + + +def _compare_name_map(db: Session) -> dict[str, str]: + rows = db.query(BizCompareTemplate.id, BizCompareTemplate.name).all() + return {str(r.id): str(r.name or "") for r in rows} + + +def ensure_port_compare_template(db: Session) -> BizCompareTemplate: + """Built-in HOW template for interface_brief (matches former port_sheet_def).""" + name = "Cutover port status" + row = db.query(BizCompareTemplate).filter(BizCompareTemplate.name == name).one_or_none() + sheet = { + "metric_id": PORT_METRIC_ID, + "key_fields": ["interface"], + "iface_fields": ["interface"], + "compare_fields": list(PORT_STATUS_FIELDS), + "display_fields": ["interface", *PORT_STATUS_FIELDS, "description"], + "row_filters": [], + "field_rules": [], + } + if row: + return row + row = BizCompareTemplate( + id=uuid4().hex, + name=name, + note="Built-in port status sheet for cutover monitor (admin/phy/prot)", + created_at=_utcnow(), + updated_at=_utcnow(), + ) + cmp_svc._apply_sheets_to_row(row, [sheet]) # noqa: SLF001 — shared normalizer + db.add(row) + db.commit() + db.refresh(row) + return row + + +def ensure_default_monitor_templates(db: Session) -> None: + """Seed once when monitor-template table is empty.""" + if db.query(BizMonitorTemplate.id).limit(1).first(): + return + cmp_svc.ensure_default_templates(db) + port_tpl = ensure_port_compare_template(db) + zte = ( + db.query(BizCompareTemplate) + .filter(BizCompareTemplate.name == "ZTE status default") + .one_or_none() + ) + seeds = [ + BizMonitorTemplate( + id=uuid4().hex, + name="端口割接监控", + compare_template_id=port_tpl.id, + collect_metric_ids_json=[PORT_METRIC_ID], + defaults_json={"dual_mode": "migrate_pair", "out_of_expect": "strict"}, + sheet_overrides_json=[ + { + "metric_id": PORT_METRIC_ID, + "status_fields": list(PORT_STATUS_FIELDS), + "down_values": ["down"], + "up_values": ["up"], + "success": [ + { + "old": ["removed", "down"], + "new": ["added", "up", "unchanged"], + } + ], + } + ], + note="Default: port status dual-verdict with up/down semantics", + created_at=_utcnow(), + updated_at=_utcnow(), + ), + ] + if zte: + seeds.append( + BizMonitorTemplate( + id=uuid4().hex, + name="ZTE 状态割接监控", + compare_template_id=zte.id, + collect_metric_ids_json=[], + defaults_json={"dual_mode": "migrate_pair", "out_of_expect": "strict"}, + sheet_overrides_json=[], + note="Uses ZTE status compare template; presence dual-verdict", + created_at=_utcnow(), + updated_at=_utcnow(), + ) + ) + for s in seeds: + db.add(s) + db.commit() + + +def list_monitor_templates(db: Session) -> list[dict[str, Any]]: + ensure_default_monitor_templates(db) + names = _compare_name_map(db) + rows = db.query(BizMonitorTemplate).order_by(BizMonitorTemplate.name.asc()).all() + return [_out(r, names.get(r.compare_template_id or "", "")) for r in rows] + + +def get_monitor_template(db: Session, template_id: str) -> dict[str, Any]: + row = db.get(BizMonitorTemplate, template_id) + if not row: + raise HTTPException(status_code=404, detail="monitor_template_not_found") + names = _compare_name_map(db) + return _out(row, names.get(row.compare_template_id or "", "")) + + +def create_monitor_template(db: Session, body: dict[str, Any]) -> dict[str, Any]: + name = str(body.get("name") or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name_required") + compare_id = str(body.get("compare_template_id") or "").strip() + if compare_id and not db.get(BizCompareTemplate, compare_id): + raise HTTPException(status_code=404, detail="compare_template_not_found") + collect = body.get("collect_metric_ids") + if not isinstance(collect, list): + collect = [] + defaults = body.get("defaults") + if not isinstance(defaults, dict): + defaults = {"dual_mode": "migrate_pair", "out_of_expect": "strict"} + overrides = body.get("sheet_overrides") + if not isinstance(overrides, list): + overrides = [] + row = BizMonitorTemplate( + id=uuid4().hex, + name=name[:256], + compare_template_id=compare_id, + collect_metric_ids_json=[str(x) for x in collect if str(x).strip()], + defaults_json=defaults, + sheet_overrides_json=overrides, + note=str(body.get("note") or "")[:512], + created_at=_utcnow(), + updated_at=_utcnow(), + ) + db.add(row) + db.commit() + db.refresh(row) + names = _compare_name_map(db) + return _out(row, names.get(row.compare_template_id or "", "")) + + +def update_monitor_template(db: Session, template_id: str, body: dict[str, Any]) -> dict[str, Any]: + row = db.get(BizMonitorTemplate, template_id) + if not row: + raise HTTPException(status_code=404, detail="monitor_template_not_found") + if "name" in body and body["name"] is not None: + name = str(body["name"] or "").strip() + if not name: + raise HTTPException(status_code=400, detail="name_required") + row.name = name[:256] + if "compare_template_id" in body and body["compare_template_id"] is not None: + compare_id = str(body["compare_template_id"] or "").strip() + if compare_id and not db.get(BizCompareTemplate, compare_id): + raise HTTPException(status_code=404, detail="compare_template_not_found") + row.compare_template_id = compare_id + if "collect_metric_ids" in body and body["collect_metric_ids"] is not None: + collect = body["collect_metric_ids"] + if not isinstance(collect, list): + raise HTTPException(status_code=400, detail="collect_metric_ids_invalid") + row.collect_metric_ids_json = [str(x) for x in collect if str(x).strip()] + if "defaults" in body and body["defaults"] is not None: + if not isinstance(body["defaults"], dict): + raise HTTPException(status_code=400, detail="defaults_invalid") + row.defaults_json = body["defaults"] + if "sheet_overrides" in body and body["sheet_overrides"] is not None: + if not isinstance(body["sheet_overrides"], list): + raise HTTPException(status_code=400, detail="sheet_overrides_invalid") + row.sheet_overrides_json = body["sheet_overrides"] + if "note" in body and body["note"] is not None: + row.note = str(body["note"] or "")[:512] + row.updated_at = _utcnow() + db.commit() + db.refresh(row) + names = _compare_name_map(db) + return _out(row, names.get(row.compare_template_id or "", "")) + + +def delete_monitor_template(db: Session, template_id: str) -> None: + row = db.get(BizMonitorTemplate, template_id) + if not row: + raise HTTPException(status_code=404, detail="monitor_template_not_found") + db.delete(row) + db.commit() diff --git a/netx_api/biz_migration_router.py b/netx_api/biz_migration_router.py index a12a7f1..2089c0f 100644 --- a/netx_api/biz_migration_router.py +++ b/netx_api/biz_migration_router.py @@ -9,6 +9,7 @@ from pydantic import BaseModel, Field from sqlalchemy.orm import Session from .biz_migration import service as svc +from .biz_migration import monitor_templates as mon_tpl from .db import get_db router = APIRouter(prefix="/v1/biz-migration", tags=["biz-migration"]) @@ -41,6 +42,24 @@ class BatchIn(BaseModel): note: str = "" +class MonitorTemplateIn(BaseModel): + name: str = "" + compare_template_id: str = "" + collect_metric_ids: list[str] = Field(default_factory=list) + defaults: dict[str, Any] = Field(default_factory=dict) + sheet_overrides: list[dict[str, Any]] = Field(default_factory=list) + note: str = "" + + +class MonitorTemplatePatchIn(BaseModel): + name: str | None = None + compare_template_id: str | None = None + collect_metric_ids: list[str] | None = None + defaults: dict[str, Any] | None = None + sheet_overrides: list[dict[str, Any]] | None = None + note: str | None = None + + class BatchPatchIn(BaseModel): batch_label: str | None = None expect_set: dict[str, Any] | None = None @@ -209,3 +228,35 @@ def api_list_diffs( offset=offset, limit=limit, ) + + +@router.get("/monitor-templates") +def api_list_monitor_templates(db: Session = Depends(get_db)): + return {"items": mon_tpl.list_monitor_templates(db)} + + +@router.post("/monitor-templates") +def api_create_monitor_template(body: MonitorTemplateIn, db: Session = Depends(get_db)): + return mon_tpl.create_monitor_template(db, body.model_dump()) + + +@router.get("/monitor-templates/{template_id}") +def api_get_monitor_template(template_id: str, db: Session = Depends(get_db)): + return mon_tpl.get_monitor_template(db, template_id) + + +@router.patch("/monitor-templates/{template_id}") +def api_patch_monitor_template( + template_id: str, + body: MonitorTemplatePatchIn, + db: Session = Depends(get_db), +): + return mon_tpl.update_monitor_template( + db, template_id, body.model_dump(exclude_unset=True) + ) + + +@router.delete("/monitor-templates/{template_id}") +def api_delete_monitor_template(template_id: str, db: Session = Depends(get_db)): + mon_tpl.delete_monitor_template(db, template_id) + return {"ok": True} diff --git a/netx_api/biz_state/schema_ensure.py b/netx_api/biz_state/schema_ensure.py index 99e2a37..b4613c0 100644 --- a/netx_api/biz_state/schema_ensure.py +++ b/netx_api/biz_state/schema_ensure.py @@ -48,6 +48,19 @@ def apply_biz_state_schema(conn: Connection) -> None: "ALTER TABLE biz_migration_batch ADD COLUMN IF NOT EXISTS accept_summary_json JSON DEFAULT '{}'", "ALTER TABLE biz_migration_run ADD COLUMN IF NOT EXISTS purpose VARCHAR(32) DEFAULT 'manual'", "CREATE INDEX IF NOT EXISTS ix_biz_migration_red_project_status ON biz_migration_red_ticket (project_id, status)", + "CREATE TABLE IF NOT EXISTS biz_monitor_template (" + "id VARCHAR(64) PRIMARY KEY," + "name VARCHAR(256) DEFAULT ''," + "compare_template_id VARCHAR(64) DEFAULT ''," + "collect_metric_ids_json JSON DEFAULT '[]'," + "defaults_json JSON DEFAULT '{}'," + "sheet_overrides_json JSON DEFAULT '[]'," + "note VARCHAR(512) DEFAULT ''," + "created_at TIMESTAMP," + "updated_at TIMESTAMP" + ")", + "CREATE INDEX IF NOT EXISTS ix_biz_monitor_template_name ON biz_monitor_template (name)", + "CREATE INDEX IF NOT EXISTS ix_biz_monitor_template_compare ON biz_monitor_template (compare_template_id)", ): try: _run_sql(conn, sql) diff --git a/netx_api/models/__init__.py b/netx_api/models/__init__.py index 7700855..be222cf 100644 --- a/netx_api/models/__init__.py +++ b/netx_api/models/__init__.py @@ -29,6 +29,7 @@ from .biz_state import ( BizCompareJob, BizCompareRun, BizCompareTemplate, + BizMonitorTemplate, BizPortMapping, BizPortMappingRow, BizStateBatch, @@ -148,6 +149,7 @@ __all__ = [ "BizStateEvent", "BizStateCommandOverride", "BizCompareTemplate", + "BizMonitorTemplate", "BizPortMapping", "BizPortMappingRow", "BizCompareJob", diff --git a/netx_api/models/biz_state.py b/netx_api/models/biz_state.py index df0b296..b24247a 100644 --- a/netx_api/models/biz_state.py +++ b/netx_api/models/biz_state.py @@ -221,6 +221,27 @@ class BizCompareTemplate(Base): ignore_fields: Mapped[list] = mapped_column(_JsonType, default=list) metrics_json: Mapped[list] = mapped_column(_JsonType, default=list) note: Mapped[str] = mapped_column(String(512), default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive, index=True) + updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) + + +class BizMonitorTemplate(Base): + """Cutover/monitor overlay on a BizCompareTemplate (HOW + WHEN rules). + + ``compare_template_id`` selects sheets / field rules. + ``defaults_json`` / ``sheet_overrides_json`` carry dual-verdict & status semantics. + ``collect_metric_ids_json`` optional HF collect subset. + """ + + __tablename__ = "biz_monitor_template" + + id: Mapped[str] = mapped_column(String(64), primary_key=True, default=lambda: uuid4().hex) + name: Mapped[str] = mapped_column(String(256), default="", index=True) + compare_template_id: Mapped[str] = mapped_column(String(64), default="", index=True) + collect_metric_ids_json: Mapped[list] = mapped_column(_JsonType, default=list) + defaults_json: Mapped[dict] = mapped_column(_JsonType, default=dict) + sheet_overrides_json: Mapped[list] = mapped_column(_JsonType, default=list) + note: Mapped[str] = mapped_column(String(512), default="") created_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) updated_at: Mapped[datetime] = mapped_column(DateTime, default=utcnow_naive) diff --git a/web/src/App.tsx b/web/src/App.tsx index 59e189a..601e088 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -52,6 +52,11 @@ const BizComparePage = lazy(() => const BizMigrationPage = lazy(() => import("./pages/network/BizMigrationPage").then((m) => ({ default: m.BizMigrationPage })), ); +const BizMonitorTemplatesPage = lazy(() => + import("./pages/network/BizMonitorTemplatesPage").then((m) => ({ + default: m.BizMonitorTemplatesPage, + })), +); const UsersPage = lazy(() => import("./pages/UsersPage").then((m) => ({ default: m.UsersPage }))); const AuditLayout = lazy(() => import("./pages/audit/AuditLayout").then((m) => ({ default: m.AuditLayout })), @@ -129,9 +134,23 @@ function ProtectedApp() { } /> } /> } /> - } /> - } /> - } /> + } /> + } + /> + } + /> + } /> + } /> + } /> + } + /> + } /> } /> } /> diff --git a/web/src/config/networkNav.ts b/web/src/config/networkNav.ts index 18b465d..a9598af 100644 --- a/web/src/config/networkNav.ts +++ b/web/src/config/networkNav.ts @@ -1,9 +1,9 @@ /** * Left-nav tree for the Network Management module. - * Add leaf items here when extending (devices / topology / alarms / tasks). + * Add leaf items here when extending (devices / topology / alarms / tasks / cutover). */ -export type NetworkNavGroupId = "detail" | "topologyMgmt" | "tasks"; +export type NetworkNavGroupId = "detail" | "topologyMgmt" | "tasks" | "cutoverCompare"; export type NetworkNavItem = { id: string; @@ -74,23 +74,41 @@ export const NETWORK_NAV: readonly NetworkNavGroup[] = [ labelKey: "network.nav.portTrafficWall", group: "tasks", }, + ], + }, + { + id: "cutoverCompare", + labelKey: "network.nav.cutoverCompare", + items: [ { id: "biz-state", - path: "/network/tasks/biz-state", + path: "/network/cutover/biz-state", labelKey: "network.nav.bizState", - group: "tasks", + group: "cutoverCompare", }, { id: "biz-compare", - path: "/network/tasks/biz-compare", + path: "/network/cutover/biz-compare", labelKey: "network.nav.bizCompare", - group: "tasks", + group: "cutoverCompare", }, { id: "biz-migration", - path: "/network/tasks/biz-migration", + path: "/network/cutover/biz-migration", labelKey: "network.nav.bizMigration", - group: "tasks", + group: "cutoverCompare", + }, + { + id: "compare-templates", + path: "/network/cutover/compare-templates", + labelKey: "network.nav.compareTemplates", + group: "cutoverCompare", + }, + { + id: "monitor-templates", + path: "/network/cutover/monitor-templates", + labelKey: "network.nav.monitorTemplates", + group: "cutoverCompare", }, ], }, diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 4529348..d15c1a0 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -144,6 +144,7 @@ const en = { nav: { detail: "Network details", tasks: "Monitoring tasks", + cutoverCompare: "Cutover compare", topologyMgmt: "Topology management", devices: "Device list", topology: "Topology", @@ -159,6 +160,8 @@ const en = { bizState: "Business monitor", bizCompare: "Business compare", bizMigration: "Cutover monitor", + compareTemplates: "Compare templates", + monitorTemplates: "Monitor templates", }, collapseNav: "Collapse sidebar", expandNav: "Expand sidebar", @@ -568,6 +571,40 @@ const en = { colStatus: "Status", verdictUnfinished: "Unfinished", }, + bizMonitorTpl: { + title: "Monitor templates", + hint: "Reference a compare template for HOW-to-compare, then add dual-device / baseline rules for cutover monitor.", + create: "New monitor template", + edit: "Edit", + save: "Save", + cancel: "Close", + created: "Created", + saved: "Saved", + deleted: "Deleted", + confirmDelete: "Delete this monitor template?", + delete: "Delete", + empty: "No monitor templates yet.", + listFilterPh: "Filter name / compare template / note", + kpiTotal: "Templates", + colName: "Name", + colCompare: "Compare template", + colCollect: "HF collect metrics", + colNote: "Note", + colActions: "Actions", + needName: "Name is required", + defaultsInvalid: "Invalid defaults JSON", + overridesInvalid: "Invalid sheet_overrides JSON", + formHint: "Compare template owns alignment & field rules; this page owns dual verdict + optional HF collect subset.", + compareHint: "From Compare templates; create/edit sheets there first if needed.", + pickCompare: "Pick compare template…", + openCompare: "Manage compare templates", + collectAll: "All sheets", + collectPh: "interface_brief, bgp_peer (empty = all)", + collectHint: "Comma-separated metric_id; empty uses every sheet on the compare template.", + defaults: "defaults (JSON)", + overrides: "sheet_overrides (JSON)", + overridesHint: "Optional per-metric status_fields / success patterns; empty array uses global defaults.", + }, portTraffic: { title: "Port traffic", create: "Monitor device", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index ea6a214..8a48197 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -144,6 +144,7 @@ const zh = { nav: { detail: "网络详情", tasks: "监控任务", + cutoverCompare: "割接对比", topologyMgmt: "拓扑管理", devices: "设备列表", topology: "拓扑信息", @@ -159,6 +160,8 @@ const zh = { bizState: "业务监控", bizCompare: "业务对比", bizMigration: "割接监控", + compareTemplates: "对比模板", + monitorTemplates: "监控模板", }, collapseNav: "折叠侧栏", expandNav: "展开侧栏", @@ -565,6 +568,40 @@ const zh = { colStatus: "状态", verdictUnfinished: "未完成", }, + bizMonitorTpl: { + title: "监控模板", + hint: "引用对比模板定义「如何比」,再叠加双端迁移/基线判定规则。供割接监控选用。", + create: "新建监控模板", + edit: "编辑", + save: "保存", + cancel: "关闭", + created: "已创建", + saved: "已保存", + deleted: "已删除", + confirmDelete: "删除该监控模板?", + delete: "删除", + empty: "暂无监控模板。", + listFilterPh: "筛选名称 / 对比模板 / 备注", + kpiTotal: "模板数", + colName: "名称", + colCompare: "对比模板", + colCollect: "高频采集项", + colNote: "备注", + colActions: "操作", + needName: "请填写名称", + defaultsInvalid: "defaults JSON 无效", + overridesInvalid: "sheet_overrides JSON 无效", + formHint: "对比模板决定对齐与字段规则;本页配置双端判定与可选高频采集子集。", + compareHint: "来自「对比模板」;可先去那边新建或编辑 sheets。", + pickCompare: "选择对比模板…", + openCompare: "管理对比模板", + collectAll: "模板全量", + collectPh: "interface_brief, bgp_peer(空=全量)", + collectHint: "逗号分隔 metric_id;留空表示使用对比模板全部 sheets。", + defaults: "defaults(JSON)", + overrides: "sheet_overrides(JSON)", + overridesHint: "可按 metric 声明 status_fields / success 模式;空数组则用全局 defaults。", + }, portTraffic: { title: "端口流量监控", create: "添加设备监控", diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index 6817461..a72a2ce 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -529,11 +529,14 @@ function metricLabel(id: string) { return id; } -export function BizComparePage() { +export type BizComparePageMode = "jobs" | "templates" | "all"; + +export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePageMode }) { const { t } = useI18n(); const { showOk, showError } = useToast(); - const [pageTab, setPageTab] = useState("jobs"); + const [pageTab, setPageTab] = useState(pageMode === "templates" ? "templates" : "jobs"); + const showTabSwitch = pageMode === "all"; const [busy, setBusy] = useState(false); const [tasks, setTasks] = useState([]); @@ -1094,7 +1097,7 @@ export function BizComparePage() { }); showOk(t("bizCompare.templateImported")); await refresh(); - setPageTab("templates"); + if (pageMode !== "jobs") setPageTab("templates"); } catch (e) { showError(formatErr(e)); } finally { @@ -1453,7 +1456,9 @@ export function BizComparePage() { return (
-

{t("bizCompare.title")}

+

+ {pageMode === "templates" ? t("bizCompare.templates") : t("bizCompare.title")} +

{pageTab === "templates" ? ( <> @@ -1488,24 +1493,26 @@ export function BizComparePage() {
-
- - -
+ {showTabSwitch ? ( +
+ + +
+ ) : null}
-

{t("bizMigration.hintPort")}

+

+ {t("bizMigration.hintPort")}{" "} + {t("network.nav.monitorTemplates")} + {" · "} + {t("network.nav.compareTemplates")} +

@@ -838,7 +843,7 @@ export function BizMigrationPage() { {!tasks.length ? (

{t("bizMigration.needBizStateTasks")}{" "} - + {t("bizMigration.openBizState")}

@@ -941,7 +946,7 @@ export function BizMigrationPage() { - + {t("bizMigration.openBizState")} {openRedCount > 0 ? ( diff --git a/web/src/pages/network/BizMonitorTemplatesPage.tsx b/web/src/pages/network/BizMonitorTemplatesPage.tsx new file mode 100644 index 0000000..99621c6 --- /dev/null +++ b/web/src/pages/network/BizMonitorTemplatesPage.tsx @@ -0,0 +1,333 @@ +import { Button, Input, Modal } from "@heroui/react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +import { Link } from "react-router-dom"; +import { AppModalShell } from "../../components/ui/AppModalShell"; +import { FieldSelect } from "../../components/ui/FieldSelect"; +import { useDebouncedValue } from "../../hooks/useDebouncedValue"; +import { useToast } from "../../hooks/useToast"; +import { useI18n } from "../../i18n"; +import { + bizCompareListTemplates, + bizMonitorCreateTemplate, + bizMonitorDeleteTemplate, + bizMonitorListTemplates, + bizMonitorUpdateTemplate, + formatErr, +} from "../../services/api"; + +type CompareTpl = { id: string; name: string }; +type MonitorTpl = { + id: string; + name: string; + compare_template_id: string; + compare_template_name?: string; + collect_metric_ids?: string[]; + defaults?: Record; + sheet_overrides?: unknown[]; + note?: string; +}; + +export function BizMonitorTemplatesPage() { + const { t } = useI18n(); + const { showOk, showError } = useToast(); + + const [items, setItems] = useState([]); + const [compareTpls, setCompareTpls] = useState([]); + const [busy, setBusy] = useState(false); + const [listKw, setListKw] = useState(""); + const debouncedKw = useDebouncedValue(listKw, 250); + + const [editOpen, setEditOpen] = useState(false); + const [editId, setEditId] = useState(""); + const [name, setName] = useState(""); + const [compareId, setCompareId] = useState(""); + const [note, setNote] = useState(""); + const [collectText, setCollectText] = useState(""); + const [defaultsText, setDefaultsText] = useState('{"dual_mode":"migrate_pair","out_of_expect":"strict"}'); + const [overridesText, setOverridesText] = useState("[]"); + + const refresh = useCallback(async () => { + const [mon, cmp] = await Promise.all([bizMonitorListTemplates(), bizCompareListTemplates()]); + setItems((mon.items || []) as MonitorTpl[]); + setCompareTpls( + ((cmp.items || []) as Record[]).map((x) => ({ + id: String(x.id || ""), + name: String(x.name || x.id || ""), + })), + ); + }, []); + + useEffect(() => { + void (async () => { + try { + await refresh(); + } catch (e) { + showError(formatErr(e)); + } + })(); + }, [refresh, showError]); + + const filtered = useMemo(() => { + const kw = debouncedKw.trim().toLowerCase(); + if (!kw) return items; + return items.filter((row) => { + const blob = `${row.name} ${row.compare_template_name || ""} ${row.note || ""} ${row.compare_template_id}`.toLowerCase(); + return blob.includes(kw); + }); + }, [items, debouncedKw]); + + const openCreate = () => { + setEditId(""); + setName(""); + setCompareId(compareTpls[0]?.id || ""); + setNote(""); + setCollectText(""); + setDefaultsText('{"dual_mode":"migrate_pair","out_of_expect":"strict"}'); + setOverridesText("[]"); + setEditOpen(true); + }; + + const openEdit = (row: MonitorTpl) => { + setEditId(row.id); + setName(row.name || ""); + setCompareId(row.compare_template_id || ""); + setNote(row.note || ""); + setCollectText((row.collect_metric_ids || []).join(", ")); + setDefaultsText(JSON.stringify(row.defaults || {}, null, 2)); + setOverridesText(JSON.stringify(row.sheet_overrides || [], null, 2)); + setEditOpen(true); + }; + + const closeEdit = () => setEditOpen(false); + + const save = async () => { + if (!name.trim()) { + showError(t("bizMonitorTpl.needName")); + return; + } + let defaults: Record = {}; + let overrides: unknown[] = []; + try { + defaults = JSON.parse(defaultsText || "{}") as Record; + if (!defaults || typeof defaults !== "object" || Array.isArray(defaults)) { + throw new Error("defaults"); + } + } catch { + showError(t("bizMonitorTpl.defaultsInvalid")); + return; + } + try { + overrides = JSON.parse(overridesText || "[]") as unknown[]; + if (!Array.isArray(overrides)) throw new Error("overrides"); + } catch { + showError(t("bizMonitorTpl.overridesInvalid")); + return; + } + const collect_metric_ids = collectText + .split(/[,;\s]+/) + .map((x) => x.trim()) + .filter(Boolean); + const body = { + name: name.trim(), + compare_template_id: compareId, + collect_metric_ids, + defaults, + sheet_overrides: overrides, + note: note.trim(), + }; + setBusy(true); + try { + if (editId) { + await bizMonitorUpdateTemplate(editId, body); + showOk(t("bizMonitorTpl.saved")); + } else { + await bizMonitorCreateTemplate(body); + showOk(t("bizMonitorTpl.created")); + } + closeEdit(); + await refresh(); + } catch (e) { + showError(formatErr(e)); + } finally { + setBusy(false); + } + }; + + const remove = async (id: string) => { + if (!window.confirm(t("bizMonitorTpl.confirmDelete"))) return; + setBusy(true); + try { + await bizMonitorDeleteTemplate(id); + showOk(t("bizMonitorTpl.deleted")); + await refresh(); + } catch (e) { + showError(formatErr(e)); + } finally { + setBusy(false); + } + }; + + return ( +
+
+

{t("bizMonitorTpl.title")}

+
+ +
+
+

{t("bizMonitorTpl.hint")}

+ +
+
+
+
{t("bizMonitorTpl.kpiTotal")}
+
{items.length}
+
+
+ +
+ setListKw(e.target.value)} + /> +
+ +
+ + + + + + + + + + + + {filtered.map((row) => ( + + + + + + + + ))} + {!filtered.length ? ( + + + + ) : null} + +
{t("bizMonitorTpl.colName")}{t("bizMonitorTpl.colCompare")}{t("bizMonitorTpl.colCollect")}{t("bizMonitorTpl.colNote")}{t("bizMonitorTpl.colActions")}
+
{row.name}
+
+ {row.compare_template_name || row.compare_template_id || "—"} + {row.compare_template_id ? ( +
+ + {t("bizMonitorTpl.openCompare")} + +
+ ) : null} +
+ {(row.collect_metric_ids || []).length + ? (row.collect_metric_ids || []).join(", ") + : t("bizMonitorTpl.collectAll")} + {row.note || "—"} +
+ + +
+
+
{t("bizMonitorTpl.empty")}
+
+
+
+ + + + + {editId ? t("bizMonitorTpl.edit") : t("bizMonitorTpl.create")} + + + + +

+ {t("bizMonitorTpl.formHint")} +

+ + setCompareId(e.target.value)} + fullWidth + hint={t("bizMonitorTpl.compareHint")} + > + + {compareTpls.map((c) => ( + + ))} + + +