diff --git a/netx_api/biz_state/compare_service.py b/netx_api/biz_state/compare_service.py index 2299128..fca470c 100644 --- a/netx_api/biz_state/compare_service.py +++ b/netx_api/biz_state/compare_service.py @@ -90,11 +90,80 @@ def _template_out(t: BizCompareTemplate) -> dict[str, Any]: def list_templates(db: Session) -> list[dict[str, Any]]: - ensure_default_lldp_template(db) + ensure_default_templates(db) rows = db.query(BizCompareTemplate).order_by(BizCompareTemplate.name.asc()).all() return [_template_out(t) for t in rows] +def ensure_default_vrf_template(db: Session) -> BizCompareTemplate: + row = ( + db.query(BizCompareTemplate) + .filter( + BizCompareTemplate.metric_id == "vrf_route_summary", + BizCompareTemplate.name == "VRF route summary default", + ) + .one_or_none() + ) + if row: + return row + fields = metric_field_map().get("vrf_route_summary") or [] + keys = [f.name for f in fields if f.is_key] or ["vrf", "source"] + ifaces = [f.name for f in fields if f.is_interface] + compare = [f.name for f in fields if f.role in ("state", "identity", "meta") or f.is_key] + seen: set[str] = set() + cmp_out: list[str] = [] + for n in compare: + if n not in seen: + seen.add(n) + cmp_out.append(n) + row = BizCompareTemplate( + id=uuid4().hex, + name="VRF route summary default", + metric_id="vrf_route_summary", + key_fields=keys, + iface_fields=ifaces, + compare_fields=cmp_out or ["networks"], + ignore_fields=[], + note="Built-in template for per-VRF route summary cutover compare", + created_at=_utcnow(), + updated_at=_utcnow(), + ) + db.add(row) + db.commit() + db.refresh(row) + return row + + +def ensure_default_templates(db: Session) -> None: + ensure_default_lldp_template(db) + ensure_default_vrf_template(db) + + +def list_metric_schemas() -> list[dict[str, Any]]: + """Field catalog for template editors (key / iface / compare pickers).""" + out: list[dict[str, Any]] = [] + for metric_id, fields in sorted(metric_field_map().items()): + if metric_id in ("vrf_list",): + continue + out.append( + { + "metric_id": metric_id, + "fields": [ + { + "name": f.name, + "display_name": f.display_name or f.name, + "dtype": f.dtype, + "is_key": bool(f.is_key), + "is_interface": bool(f.is_interface), + "role": f.role, + } + for f in fields + ], + } + ) + return out + + def create_template(db: Session, body: dict[str, Any]) -> dict[str, Any]: metric_id = str(body.get("metric_id") or "").strip() if not metric_id: @@ -125,6 +194,10 @@ def update_template(db: Session, template_id: str, body: dict[str, Any]) -> dict raise HTTPException(status_code=404, detail="template_not_found") if "name" in body: t.name = str(body.get("name") or "")[:256] + if "metric_id" in body and body.get("metric_id") is not None: + mid = str(body.get("metric_id") or "").strip() + if mid: + t.metric_id = mid if "key_fields" in body: keys = [str(x) for x in (body.get("key_fields") or []) if str(x).strip()] if not keys: @@ -279,6 +352,7 @@ def validate_mapping( after_batch_id: str, template_id: str = "", ) -> dict[str, Any]: + ensure_default_templates(db) tpl = db.get(BizCompareTemplate, template_id) if template_id else ensure_default_lldp_template(db) if not tpl: raise HTTPException(status_code=404, detail="template_not_found") @@ -316,7 +390,7 @@ def list_jobs(db: Session) -> list[dict[str, Any]]: def create_job(db: Session, body: dict[str, Any]) -> dict[str, Any]: - ensure_default_lldp_template(db) + ensure_default_templates(db) template_id = str(body.get("template_id") or "").strip() if not template_id: tpl = ensure_default_lldp_template(db) @@ -458,6 +532,7 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]: r = db.get(BizCompareRun, run_id) if not r: raise HTTPException(status_code=404, detail="run_not_found") + tpl = db.get(BizCompareTemplate, r.template_id) if r.template_id else None return { "id": r.id, "job_id": r.job_id, @@ -472,6 +547,7 @@ def get_run(db: Session, run_id: str) -> dict[str, Any]: "mapping_stats": r.mapping_stats_json or {}, "message": r.message, "created_at": r.created_at.isoformat() + "Z" if r.created_at else None, + "template": _template_out(tpl) if tpl else None, } diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index 443fa56..4aed1ba 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -222,6 +222,16 @@ class TemplateIn(BaseModel): note: str = "" +class TemplatePatchIn(BaseModel): + name: str | None = None + metric_id: str | None = None + key_fields: list[str] | None = None + iface_fields: list[str] | None = None + compare_fields: list[str] | None = None + ignore_fields: list[str] | None = None + note: str | None = None + + class MappingRowIn(BaseModel): before_if: str after_if: str @@ -252,6 +262,11 @@ class CompareJobIn(BaseModel): note: str = "" +@router.get("/compare/metrics") +def api_list_compare_metrics() -> dict[str, Any]: + return {"items": cmp_svc.list_metric_schemas()} + + @router.get("/compare/templates") def api_list_templates(db: Session = Depends(get_db)) -> dict[str, Any]: return {"items": cmp_svc.list_templates(db)} @@ -264,7 +279,7 @@ def api_create_template(body: TemplateIn, db: Session = Depends(get_db)) -> dict @router.patch("/compare/templates/{template_id}") def api_patch_template( - template_id: str, body: TemplateIn, db: Session = Depends(get_db) + template_id: str, body: TemplatePatchIn, db: Session = Depends(get_db) ) -> dict[str, Any]: return cmp_svc.update_template(db, template_id, body.model_dump(exclude_unset=True)) diff --git a/web/WEB.md b/web/WEB.md index 70fdf2e..5dad477 100644 --- a/web/WEB.md +++ b/web/WEB.md @@ -45,7 +45,7 @@ src/ | `/network/tasks/port-traffic` | 端口流量监控(设备管理) | `network` | | `/network/tasks/port-traffic/wall` | 流量大屏列表(打开独立页签) | `network` | | `/network/tasks/biz-state` | 业务状态监控(LLDP / VRF 关联采集) | `network` | -| `/network/tasks/biz-compare` | 业务状态比对(模板/映射/前后 diff) | `network` | +| `/network/tasks/biz-compare` | 业务状态比对(模板 + 任务 + pre/post 结果) | `network` | | `/port-traffic/wall/:boardId` | 流量大屏专有页签(无网络侧栏) | `port-traffic-wall` | | `/topology` | 拓扑管理(模式化编辑器:选择/平移/拖动/连线、框选、自动布局、拖放添加) | `topology` | | `/webcrt` | WebCRT 终端 | `webcrt` | @@ -131,7 +131,8 @@ src/ - API:`/v1/biz-state/profiles`、`/discover`、`/tasks*`、`/batches*`、`/batches/{id}/export`、`/compare/*` - **ParseProfile**:命令模板 + TextFSM + 回调 + schema;LLDP 与拓扑 **共享解析**(`lldp_shared`),业务流程写批次表,拓扑写 Fabric - Phase1:LLDP 邻居快照;建任务默认启用对应厂商 LLDP profile;支持自定义只采不解析行 -- Phase2:比对模板、端口映射、CompareJob(manual/auto)、`/network/tasks/biz-compare` +- Phase2:比对模板(可选 metric / key / 接口 / 比对字段)、端口映射、CompareJob 列表与详情、`pre/post` 结果表(高亮 + 筛选)、`/network/tasks/biz-compare` +- API 补充:`GET /v1/biz-state/compare/metrics`(模板字段目录) - Phase3:VRF 发现→人选关联→带参采集(`*.route_vrf_summary`);`PUT …/items/{id}/bindings` - 调度:`NETX_BIZ_STATE_SCHEDULER_ENABLED`(默认开),tick `NETX_BIZ_STATE_SCHEDULER_TICK_SEC` - 前端:`/network/tasks/biz-state`(勾选监控项 / 发现 VRF / 批次 / 导出 zip) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index bc30056..2539e1f 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -229,14 +229,36 @@ const en = { }, bizCompare: { title: "Business compare", + templates: "Compare templates", + newTemplate: "New template", + editTemplate: "Edit template", + saveTemplate: "Save template", + templateSaved: "Template saved", + templateDeleted: "Template deleted", + confirmDeleteTemplate: "Delete this compare template?", + emptyTemplates: "No templates yet. Create one or use built-ins.", + templateHint: + "Field roles: Key aligns rows; interface fields use port mapping; compare fields show pre/post; ignore skips diff.", + keyFields: "Key", + ifaceFields: "Interface", + compareFields: "Compare", + ignoreFields: "Ignore", + field: "Field", + note: "Note", + keyRequired: "Select at least one key field", + noMetricFields: "No fields for this metric", + templateFilterPh: "Filter template / metric", + jobFilterPh: "Filter job / template / status", mapping: "Port mapping", newMapping: "New mapping…", mapName: "Mapping name", saveMapping: "Save mapping", validateMapping: "Validate", + validateNeed: "Select mapping and before/after batches first", mapHint: "before_if,after_if one pair per line", mappingSaved: "Mapping saved", createJob: "Create compare job", + defaultJobName: "Cutover compare", jobName: "Name", template: "Template", mode: "Mode", @@ -251,10 +273,25 @@ const en = { autoHint: "auto: new after-task batches trigger compare", createCompare: "Create job", runNow: "Run now", + saveJob: "Save config", + jobSaved: "Job config saved", jobList: "Compare jobs", + detail: "Details", + edit: "Edit", + delete: "Delete", + tabConfig: "Config / batches", + tabResult: "Result", runs: "Run history", + noRuns: "No runs yet — run a compare first", result: "Result", - pickRun: "Pick a run or click Run now", + pickRun: "Pick a run below or click Run now", + resultEmpty: "No matching diff rows", + resultFilterPh: "Filter key / values…", + kindAll: "All kinds", + kindAdded: "Added only", + kindRemoved: "Removed only", + kindChanged: "Changed only", + kindUnchanged: "Unchanged only", emptyJobs: "No compare jobs yet", created: "Compare job created", ran: "Compare finished", @@ -262,6 +299,7 @@ const en = { colMode: "Mode", colStatus: "Status", colKind: "Kind", + colActions: "Actions", colKey: "Key", colChange: "Changes", }, diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 1531d6b..5159d78 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -229,14 +229,35 @@ const zh = { }, bizCompare: { title: "业务状态比对", + templates: "比对模板", + newTemplate: "新建模板", + editTemplate: "编辑模板", + saveTemplate: "保存模板", + templateSaved: "模板已保存", + templateDeleted: "模板已删除", + confirmDeleteTemplate: "删除该比对模板?", + emptyTemplates: "暂无模板。可新建或使用系统默认。", + templateHint: "勾选字段角色:Key 用于对齐行;接口字段参与端口映射;比对字段左右对照;忽略字段不参与 diff。", + keyFields: "Key", + ifaceFields: "接口", + compareFields: "比对", + ignoreFields: "忽略", + field: "字段", + note: "备注", + keyRequired: "至少选择一个 Key 字段", + noMetricFields: "该 metric 无字段定义", + templateFilterPh: "筛选模板名 / metric", + jobFilterPh: "筛选任务名 / 模板 / 状态", mapping: "端口映射", newMapping: "新建映射…", mapName: "映射名称", saveMapping: "保存映射", validateMapping: "校验映射", + validateNeed: "请先选择映射与前后批次", mapHint: "before_if,after_if 每行一对", mappingSaved: "映射已保存", createJob: "建立比对任务", + defaultJobName: "割接比对", jobName: "名称", template: "模板", mode: "模式", @@ -251,10 +272,25 @@ const zh = { autoHint: "auto:操作后任务新批次到达会自动跑比对", createCompare: "创建比对任务", runNow: "立即比对", - jobList: "比对任务列表", + saveJob: "保存配置", + jobSaved: "任务配置已保存", + jobList: "比对任务", + detail: "详情", + edit: "编辑", + delete: "删除", + tabConfig: "配置 / 批次", + tabResult: "比对结果", runs: "历史 Run", + noRuns: "尚无比对记录,请先执行比对", result: "比对结果", - pickRun: "选择 Run 或点「立即比对」", + pickRun: "选择底部 Run 或点「立即比对」", + resultEmpty: "无匹配差异行", + resultFilterPh: "筛选 key / 字段值…", + kindAll: "全部类型", + kindAdded: "仅新增", + kindRemoved: "仅删除", + kindChanged: "仅变更", + kindUnchanged: "仅未变", emptyJobs: "暂无比对任务", created: "比对任务已创建", ran: "比对完成", @@ -262,6 +298,7 @@ const zh = { colMode: "模式", colStatus: "状态", colKind: "类型", + colActions: "操作", colKey: "Key", colChange: "变更", }, diff --git a/web/src/index.css b/web/src/index.css index 1ab91dd..d6be363 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -9993,6 +9993,49 @@ html.login-page--paused .login-page__flare { margin-bottom: 4px; } +/* biz_state compare result workbook */ +.bs-cmp-job-body { + min-height: min(64vh, 680px); +} + +.bs-cmp-result-table { + flex: 1 1 auto; + min-height: 260px; + max-height: min(46vh, 520px); +} + +.bs-cmp-diff-table .bs-cmp-pair-head { + text-align: center; +} + +.bs-cmp-diff-table .bs-cmp-pre-head, +.bs-cmp-diff-table .bs-cmp-post-head { + font-size: 11px; + opacity: 0.8; +} + +.bs-cmp-row--added { + background: rgba(34, 197, 94, 0.08); +} + +.bs-cmp-row--removed { + background: rgba(239, 68, 68, 0.08); +} + +.bs-cmp-row--changed { + background: rgba(245, 158, 11, 0.06); +} + +.bs-cmp-cell--pre { + background: rgba(239, 68, 68, 0.14); + font-weight: 600; +} + +.bs-cmp-cell--post { + background: rgba(34, 197, 94, 0.14); + font-weight: 600; +} + .nm-config-modal__body { flex: 1 1 auto; width: 100%; diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index 5628d9c..94f2822 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -1,19 +1,25 @@ -import { Button, Input } from "@heroui/react"; -import { useCallback, useEffect, useState } from "react"; +import { Button, Input, Modal } from "@heroui/react"; +import { useCallback, useEffect, useMemo, useState } from "react"; +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 { bizCompareCreateJob, bizCompareCreateMapping, + bizCompareCreateTemplate, + bizCompareDeleteTemplate, bizCompareGetRun, bizCompareListJobs, bizCompareListMappings, + bizCompareListMetrics, bizCompareListRuns, bizCompareListTemplates, bizCompareRunJob, bizCompareUpdateJob, bizCompareUpdateMapping, + bizCompareUpdateTemplate, bizCompareValidateMapping, bizStateListBatches, bizStateListTasks, @@ -22,9 +28,31 @@ import { import { formatSystemTime } from "../../utils/time"; import { jobChipColor, NmStatusChip } from "./nmChips"; +type PageTab = "templates" | "jobs"; +type JobDetailTab = "config" | "result"; +type KindFilter = "all" | "added" | "removed" | "changed" | "unchanged"; + type TaskOpt = { id: string; ne_name: string; ne_ip: string; vendor: string }; type BatchOpt = { id: string; status: string; row_count: number; started_at?: string | null }; -type Template = { id: string; name: string; metric_id: string }; +type MetricField = { + name: string; + display_name: string; + dtype: string; + is_key: boolean; + is_interface: boolean; + role: string; +}; +type MetricSchema = { metric_id: string; fields: MetricField[] }; +type Template = { + id: string; + name: string; + metric_id: string; + key_fields: string[]; + iface_fields: string[]; + compare_fields: string[]; + ignore_fields: string[]; + note: string; +}; type Mapping = { id: string; name: string; rows: { before_if: string; after_if: string }[] }; type Job = { id: string; @@ -37,6 +65,16 @@ type Job = { after_batch_id: string; mode: string; status: string; + note?: string; +}; + +type DiffRow = { + kind: string; + key: Record; + before: Record | null; + after: Record | null; + mapped_before?: Record | null; + changes: Record; }; function fmtTime(v?: string | null) { @@ -44,19 +82,52 @@ function fmtTime(v?: string | null) { return formatSystemTime(v) || v; } +function cellText(v: unknown): string { + if (v === null || v === undefined) return ""; + if (typeof v === "object") return JSON.stringify(v); + return String(v); +} + +function toggleInList(list: string[], name: string, on: boolean): string[] { + if (on) return list.includes(name) ? list : [...list, name]; + return list.filter((x) => x !== name); +} + +function taskLabel(row: TaskOpt) { + return `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`; +} + export function BizComparePage() { const { t } = useI18n(); const { showOk, showError } = useToast(); + + const [pageTab, setPageTab] = useState("jobs"); + const [busy, setBusy] = useState(false); + const [tasks, setTasks] = useState([]); + const [metrics, setMetrics] = useState([]); const [templates, setTemplates] = useState([]); const [mappings, setMappings] = useState([]); const [jobs, setJobs] = useState([]); - const [selectedJobId, setSelectedJobId] = useState(""); - const [runs, setRuns] = useState([]); - const [runDetail, setRunDetail] = useState(null); - const [busy, setBusy] = useState(false); + const [listKw, setListKw] = useState(""); + const debouncedListKw = useDebouncedValue(listKw, 250); - const [name, setName] = useState("割接比对"); + // template editor + const [tplOpen, setTplOpen] = useState(false); + const [tplEditId, setTplEditId] = useState(""); + const [tplName, setTplName] = useState(""); + const [tplMetric, setTplMetric] = useState("lldp_neighbor"); + const [tplKeys, setTplKeys] = useState([]); + const [tplIfaces, setTplIfaces] = useState([]); + const [tplCompare, setTplCompare] = useState([]); + const [tplIgnore, setTplIgnore] = useState([]); + const [tplNote, setTplNote] = useState(""); + + // job create / detail + const [jobCreateOpen, setJobCreateOpen] = useState(false); + const [jobId, setJobId] = useState(""); + const [jobDetailTab, setJobDetailTab] = useState("config"); + const [name, setName] = useState(""); const [templateId, setTemplateId] = useState(""); const [mappingId, setMappingId] = useState(""); const [beforeTaskId, setBeforeTaskId] = useState(""); @@ -66,25 +137,31 @@ export function BizComparePage() { const [beforeBatchId, setBeforeBatchId] = useState(""); const [afterBatchId, setAfterBatchId] = useState(""); const [mode, setMode] = useState<"manual" | "auto">("manual"); - const [mapName, setMapName] = useState("端口映射"); const [mapText, setMapText] = useState("before_if,after_if\n"); const [validateOut, setValidateOut] = useState(null); + const [runs, setRuns] = useState([]); + const [runDetail, setRunDetail] = useState(null); + + // result filters + const [kindFilter, setKindFilter] = useState("all"); + const [resultKw, setResultKw] = useState(""); + const debouncedResultKw = useDebouncedValue(resultKw, 200); const refresh = useCallback(async () => { - const [taskRes, tpl, maps, j] = await Promise.all([ + const [taskRes, tpl, maps, j, met] = await Promise.all([ bizStateListTasks(), bizCompareListTemplates(), bizCompareListMappings(), bizCompareListJobs(), + bizCompareListMetrics(), ]); setTasks((taskRes.items || []) as TaskOpt[]); setTemplates((tpl.items || []) as Template[]); setMappings((maps.items || []) as Mapping[]); setJobs((j.items || []) as Job[]); - const items = (tpl.items || []) as Template[]; - if (!templateId && items[0]) setTemplateId(items[0].id); - }, [templateId]); + setMetrics((met.items || []) as MetricSchema[]); + }, []); useEffect(() => { void (async () => { @@ -94,7 +171,7 @@ export function BizComparePage() { showError(formatErr(e)); } })(); - // eslint-disable-next-line react-hooks/exhaustive-deps -- mount once + // eslint-disable-next-line react-hooks/exhaustive-deps }, [refresh]); useEffect(() => { @@ -119,6 +196,138 @@ export function BizComparePage() { })(); }, [afterTaskId]); + const metricFields = useMemo(() => { + return metrics.find((m) => m.metric_id === tplMetric)?.fields || []; + }, [metrics, tplMetric]); + + const filteredJobs = useMemo(() => { + const kw = debouncedListKw.trim().toLowerCase(); + if (!kw) return jobs; + return jobs.filter((j) => { + const tpl = templates.find((x) => x.id === j.template_id); + return `${j.name} ${j.mode} ${j.status} ${tpl?.name || ""} ${tpl?.metric_id || ""}` + .toLowerCase() + .includes(kw); + }); + }, [jobs, templates, debouncedListKw]); + + const filteredTemplates = useMemo(() => { + const kw = debouncedListKw.trim().toLowerCase(); + if (!kw) return templates; + return templates.filter((x) => + `${x.name} ${x.metric_id} ${x.note}`.toLowerCase().includes(kw), + ); + }, [templates, debouncedListKw]); + + const activeTemplate = useMemo( + () => templates.find((x) => x.id === (runDetail?.template_id || templateId)), + [templates, runDetail, templateId], + ); + + const resultColumns = useMemo(() => { + const tpl = (runDetail?.template as Template | null) || activeTemplate; + const keys = tpl?.key_fields?.length ? tpl.key_fields : Object.keys((runDetail?.diffs?.[0]?.key as any) || {}); + const compare = (tpl?.compare_fields || []).filter((f) => !keys.includes(f)); + return { keys, compare }; + }, [runDetail, activeTemplate]); + + const filteredDiffs = useMemo(() => { + const diffs = (runDetail?.diffs || []) as DiffRow[]; + const kw = debouncedResultKw.trim().toLowerCase(); + return diffs.filter((d) => { + if (kindFilter !== "all" && d.kind !== kindFilter) return false; + if (!kw) return true; + const blob = [ + d.kind, + ...Object.values(d.key || {}), + ...Object.values(d.before || {}), + ...Object.values(d.after || {}), + JSON.stringify(d.changes || {}), + ] + .map(cellText) + .join(" ") + .toLowerCase(); + return blob.includes(kw); + }); + }, [runDetail, kindFilter, debouncedResultKw]); + + const openNewTemplate = () => { + const first = metrics[0]?.metric_id || "lldp_neighbor"; + const fields = metrics.find((m) => m.metric_id === first)?.fields || []; + setTplEditId(""); + setTplName(""); + setTplMetric(first); + setTplKeys(fields.filter((f) => f.is_key).map((f) => f.name)); + setTplIfaces(fields.filter((f) => f.is_interface).map((f) => f.name)); + setTplCompare(fields.map((f) => f.name)); + setTplIgnore([]); + setTplNote(""); + setTplOpen(true); + }; + + const openEditTemplate = (tpl: Template) => { + setTplEditId(tpl.id); + setTplName(tpl.name); + setTplMetric(tpl.metric_id); + setTplKeys([...(tpl.key_fields || [])]); + setTplIfaces([...(tpl.iface_fields || [])]); + setTplCompare([...(tpl.compare_fields || [])]); + setTplIgnore([...(tpl.ignore_fields || [])]); + setTplNote(tpl.note || ""); + setTplOpen(true); + }; + + const onMetricChange = (metricId: string) => { + setTplMetric(metricId); + const fields = metrics.find((m) => m.metric_id === metricId)?.fields || []; + setTplKeys(fields.filter((f) => f.is_key).map((f) => f.name)); + setTplIfaces(fields.filter((f) => f.is_interface).map((f) => f.name)); + setTplCompare(fields.map((f) => f.name)); + setTplIgnore([]); + }; + + const saveTemplate = async () => { + if (!tplKeys.length) { + showError(t("bizCompare.keyRequired")); + return; + } + setBusy(true); + try { + const body = { + name: tplName || tplMetric, + metric_id: tplMetric, + key_fields: tplKeys, + iface_fields: tplIfaces, + compare_fields: tplCompare, + ignore_fields: tplIgnore, + note: tplNote, + }; + if (tplEditId) await bizCompareUpdateTemplate(tplEditId, body); + else await bizCompareCreateTemplate(body); + showOk(t("bizCompare.templateSaved")); + setTplOpen(false); + await refresh(); + } catch (e) { + showError(formatErr(e)); + } finally { + setBusy(false); + } + }; + + const removeTemplate = async (id: string) => { + if (!window.confirm(t("bizCompare.confirmDeleteTemplate"))) return; + setBusy(true); + try { + await bizCompareDeleteTemplate(id); + showOk(t("bizCompare.templateDeleted")); + await refresh(); + } catch (e) { + showError(formatErr(e)); + } finally { + setBusy(false); + } + }; + const parseMapRows = () => { const rows: { before_if: string; after_if: string }[] = []; for (const line of mapText.split(/\r?\n/)) { @@ -134,13 +343,20 @@ export function BizComparePage() { return rows; }; - const updateExistingMapping = async () => { + const loadMappingText = (id: string) => { + const m = mappings.find((x) => x.id === id); + if (!m) return; + setMappingId(id); + setMapName(m.name); + setMapText(["before_if,after_if", ...m.rows.map((r) => `${r.before_if},${r.after_if}`)].join("\n")); + }; + + const saveMapping = async () => { setBusy(true); try { const rows = parseMapRows(); - if (mappingId) { - await bizCompareUpdateMapping(mappingId, { name: mapName, rows }); - } else { + if (mappingId) await bizCompareUpdateMapping(mappingId, { name: mapName, rows }); + else { const m = await bizCompareCreateMapping({ name: mapName, rows }); setMappingId(String(m.id)); } @@ -155,7 +371,7 @@ export function BizComparePage() { const doValidate = async () => { if (!mappingId || !beforeBatchId || !afterBatchId) { - showError(`${t("bizCompare.validateMapping")}: mapping / batches`); + showError(t("bizCompare.validateNeed")); return; } setBusy(true); @@ -174,6 +390,28 @@ export function BizComparePage() { } }; + const resetJobForm = (preset?: Partial) => { + setName(preset?.name || t("bizCompare.defaultJobName")); + setTemplateId(preset?.template_id || templates[0]?.id || ""); + setMappingId(preset?.mapping_id || ""); + setBeforeTaskId(preset?.before_task_id || ""); + setAfterTaskId(preset?.after_task_id || ""); + setBeforeBatchId(preset?.before_batch_id || ""); + setAfterBatchId(preset?.after_batch_id || ""); + setMode(preset?.mode === "auto" ? "auto" : "manual"); + setValidateOut(null); + if (preset?.mapping_id) loadMappingText(preset.mapping_id); + else { + setMapName(t("bizCompare.mapping")); + setMapText("before_if,after_if\n"); + } + }; + + const openCreateJob = () => { + resetJobForm(); + setJobCreateOpen(true); + }; + const createJob = async () => { setBusy(true); try { @@ -187,11 +425,10 @@ export function BizComparePage() { after_batch_id: mode === "manual" ? afterBatchId : "", mode, }); - setSelectedJobId(String(j.id)); showOk(t("bizCompare.created")); + setJobCreateOpen(false); await refresh(); - const r = await bizCompareListRuns(String(j.id)); - setRuns(r.items || []); + await openJob(String(j.id)); } catch (e) { showError(formatErr(e)); } finally { @@ -200,40 +437,73 @@ export function BizComparePage() { }; const openJob = async (id: string) => { - setSelectedJobId(id); + setJobId(id); + setJobDetailTab("config"); setRunDetail(null); - const r = await bizCompareListRuns(id); - setRuns(r.items || []); + setKindFilter("all"); + setResultKw(""); const job = jobs.find((x) => x.id === id); - if (job) { - setName(job.name); - setTemplateId(job.template_id); - setMappingId(job.mapping_id); - setBeforeTaskId(job.before_task_id); - setAfterTaskId(job.after_task_id); - setBeforeBatchId(job.before_batch_id); - setAfterBatchId(job.after_batch_id); - setMode(job.mode === "auto" ? "auto" : "manual"); - if (job.mapping_id) loadMappingText(job.mapping_id); + if (job) resetJobForm(job); + try { + const r = await bizCompareListRuns(id); + setRuns(r.items || []); + if ((r.items || []).length) { + const latest = await bizCompareGetRun(String((r.items as any[])[0].id)); + setRunDetail(latest); + setJobDetailTab("result"); + } + } catch (e) { + showError(formatErr(e)); + } + }; + + const closeJob = () => { + setJobId(""); + setRuns([]); + setRunDetail(null); + }; + + const saveJobConfig = async () => { + if (!jobId) return; + setBusy(true); + try { + await bizCompareUpdateJob(jobId, { + name, + template_id: templateId, + mapping_id: mappingId, + before_task_id: beforeTaskId, + after_task_id: afterTaskId || beforeTaskId, + before_batch_id: beforeBatchId, + after_batch_id: mode === "manual" ? afterBatchId : "", + mode, + }); + showOk(t("bizCompare.jobSaved")); + await refresh(); + } catch (e) { + showError(formatErr(e)); + } finally { + setBusy(false); } }; const runNow = async () => { - if (!selectedJobId) return; + if (!jobId) return; setBusy(true); try { - await bizCompareUpdateJob(selectedJobId, { + await bizCompareUpdateJob(jobId, { before_batch_id: beforeBatchId, after_batch_id: mode === "manual" ? afterBatchId : "", mode, mapping_id: mappingId, template_id: templateId, }); - const run = await bizCompareRunJob(selectedJobId); + const run = await bizCompareRunJob(jobId); setRunDetail(run); + setJobDetailTab("result"); showOk(t("bizCompare.ran")); - const r = await bizCompareListRuns(selectedJobId); + const r = await bizCompareListRuns(jobId); setRuns(r.items || []); + await refresh(); } catch (e) { showError(formatErr(e)); } finally { @@ -245,291 +515,622 @@ export function BizComparePage() { try { const d = await bizCompareGetRun(runId); setRunDetail(d); + setJobDetailTab("result"); } catch (e) { showError(formatErr(e)); } }; - const loadMappingText = (id: string) => { - const m = mappings.find((x) => x.id === id); - if (!m) return; - setMappingId(id); - setMapName(m.name); - setMapText(["before_if,after_if", ...m.rows.map((r) => `${r.before_if},${r.after_if}`)].join("\n")); - }; + const summary = runDetail?.summary || {}; - const taskLabel = (row: TaskOpt) => `${row.ne_name || row.ne_ip || row.id} (${row.vendor || "-"})`; + const renderJobForm = (compact = false) => ( +
+ + setTemplateId(e.target.value)} + fullWidth + > + {templates.map((tpl) => ( + + ))} + + setMode(e.target.value as "manual" | "auto")} + fullWidth + > + + + + setBeforeTaskId(e.target.value)} + fullWidth + > + + {tasks.map((row) => ( + + ))} + + setBeforeBatchId(e.target.value)} + fullWidth + > + + {beforeBatches.map((b) => ( + + ))} + + setAfterTaskId(e.target.value)} + fullWidth + > + + {tasks.map((row) => ( + + ))} + + {mode === "manual" ? ( + setAfterBatchId(e.target.value)} + fullWidth + > + + {afterBatches.map((b) => ( + + ))} + + ) : ( +

{t("bizCompare.autoHint")}

+ )} + + {!compact ? ( +
+

{t("bizCompare.mapping")}

+
+ { + const id = e.target.value; + if (id) loadMappingText(id); + else { + setMappingId(""); + setMapText("before_if,after_if\n"); + } + }} + > + + {mappings.map((m) => ( + + ))} + + setMapName(e.target.value)} /> + + +
+