Improve biz-compare UX with guided create, sheet toggles, and per-run delete.

Also sticky template field headers and biz-state pause/start endpoints.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-20 16:29:50 +08:00
parent 9b3e98ede5
commit 3d2489041a
12 changed files with 669 additions and 155 deletions

View file

@ -1248,11 +1248,22 @@ def _job_out(j: BizCompareJob) -> dict[str, Any]:
"after_batch_id": j.after_batch_id,
"mode": j.mode,
"status": j.status,
"enabled_sheet_ids": _str_list(getattr(j, "enabled_sheet_ids", None)),
"note": j.note,
"updated_at": j.updated_at.isoformat() + "Z" if j.updated_at else None,
}
def _filter_enabled_sheets(
sheets_cfg: list[dict[str, Any]], enabled_sheet_ids: list[str] | None
) -> list[dict[str, Any]]:
"""Empty enabled list → all sheets; else keep matching sheet_id only."""
allowed = set(_str_list(enabled_sheet_ids))
if not allowed:
return sheets_cfg
return [s for s in sheets_cfg if sheet_key(s) in allowed]
def list_jobs(db: Session) -> list[dict[str, Any]]:
rows = db.query(BizCompareJob).order_by(BizCompareJob.updated_at.desc()).all()
return [_job_out(j) for j in rows]
@ -1267,6 +1278,7 @@ def create_job(db: Session, body: dict[str, Any]) -> dict[str, Any]:
else:
if not db.get(BizCompareTemplate, template_id):
raise HTTPException(status_code=404, detail="template_not_found")
enabled = _str_list(body.get("enabled_sheet_ids"))
j = BizCompareJob(
id=uuid4().hex,
name=str(body.get("name") or "compare")[:256],
@ -1278,6 +1290,7 @@ def create_job(db: Session, body: dict[str, Any]) -> dict[str, Any]:
after_batch_id=str(body.get("after_batch_id") or ""),
mode=str(body.get("mode") or "manual")[:16],
status="ready",
enabled_sheet_ids=enabled,
note=str(body.get("note") or "")[:512],
created_at=_utcnow(),
updated_at=_utcnow(),
@ -1307,6 +1320,8 @@ def update_job(db: Session, job_id: str, body: dict[str, Any]) -> dict[str, Any]
):
if key in body and body.get(key) is not None:
setattr(j, key, str(body.get(key) or ""))
if "enabled_sheet_ids" in body:
j.enabled_sheet_ids = _str_list(body.get("enabled_sheet_ids"))
j.updated_at = _utcnow()
db.commit()
return _job_out(j)
@ -1328,6 +1343,20 @@ def delete_job(db: Session, job_id: str) -> None:
db.commit()
def delete_run(db: Session, run_id: str) -> dict[str, Any]:
"""Delete one compare run and its diffs; leave the job intact."""
r = db.get(BizCompareRun, run_id)
if not r:
raise HTTPException(status_code=404, detail="run_not_found")
job_id = str(r.job_id or "")
db.query(BizCompareDiff).filter(BizCompareDiff.run_id == run_id).delete(
synchronize_session=False
)
db.delete(r)
db.commit()
return {"ok": True, "job_id": job_id, "run_id": run_id}
def _resolve_after_batch(db: Session, job: BizCompareJob) -> str:
if job.mode != "auto":
return str(job.after_batch_id or "")
@ -1423,6 +1452,9 @@ def run_compare(db: Session, job_id: str, *, force_after_batch_id: str = "") ->
sheets_cfg = template_metrics(tpl)
if not sheets_cfg:
raise HTTPException(status_code=400, detail="template_has_no_metrics")
sheets_cfg = _filter_enabled_sheets(sheets_cfg, getattr(j, "enabled_sheet_ids", None))
if not sheets_cfg:
raise HTTPException(status_code=400, detail="no_enabled_sheets")
pmap = _port_map_dict(db, j.mapping_id)
norm_rules = template_iface_normalize(tpl)

View file

@ -22,6 +22,7 @@ def apply_biz_state_schema(conn: Connection) -> None:
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_task_id ON biz_state_batch (task_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_batch_command_batch_id ON biz_state_batch_command (batch_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_lldp_neighbor_batch_id ON biz_state_lldp_neighbor (batch_id)",
"ALTER TABLE biz_compare_job ADD COLUMN IF NOT EXISTS enabled_sheet_ids JSON DEFAULT '[]'",
"CREATE INDEX IF NOT EXISTS ix_biz_compare_job_status ON biz_compare_job (status)",
"CREATE INDEX IF NOT EXISTS ix_biz_compare_run_job_id ON biz_compare_run (job_id)",
"CREATE INDEX IF NOT EXISTS ix_biz_state_vrf_route_batch_id ON biz_state_vrf_route_summary (batch_id)",

View file

@ -235,6 +235,11 @@ def _replace_items(db: Session, task_id: str, items_in: list[dict[str, Any]]) ->
)
def set_task_status(db: Session, task_id: str, status: str) -> dict[str, Any]:
"""Set lifecycle status: draft | running | paused | stopped."""
return update_task(db, task_id, {"status": status})
def update_task(db: Session, task_id: str, body: dict[str, Any]) -> dict[str, Any]:
task = db.get(BizStateTask, task_id)
if not task:

View file

@ -174,6 +174,18 @@ def api_patch_task(
return svc.update_task(db, task_id, body.model_dump(exclude_unset=True))
@router.post("/tasks/{task_id}/pause")
def api_pause_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
"""Pause periodic schedule; manual collect remains allowed."""
return svc.set_task_status(db, task_id, "paused")
@router.post("/tasks/{task_id}/start")
def api_start_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
"""Enable periodic schedule (requires bindings for non–cutover-HF tasks)."""
return svc.set_task_status(db, task_id, "running")
@router.delete("/tasks/{task_id}")
def api_delete_task(task_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
svc.delete_task(db, task_id)
@ -374,6 +386,8 @@ class CompareJobIn(BaseModel):
before_batch_id: str = ""
after_batch_id: str = ""
mode: str = "manual"
# Empty = all template sheets; non-empty = only these sheet_id values
enabled_sheet_ids: list[str] = Field(default_factory=list)
note: str = ""
@ -478,6 +492,11 @@ def api_get_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.get_run(db, run_id)
@router.delete("/compare/runs/{run_id}")
def api_delete_run(run_id: str, db: Session = Depends(get_db)) -> dict[str, Any]:
return cmp_svc.delete_run(db, run_id)
@router.get("/compare/runs/{run_id}/diffs")
def api_list_run_diffs(
run_id: str,

View file

@ -293,6 +293,8 @@ class BizCompareJob(Base):
# manual: fixed after_batch; auto: after_batch_id empty → use latest after task batch
mode: Mapped[str] = mapped_column(String(16), default="manual", index=True)
status: Mapped[str] = mapped_column(String(32), default="draft", index=True) # draft|ready|auto
# Empty = all template sheets; non-empty = only these sheet_id values
enabled_sheet_ids: 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)

View file

@ -444,6 +444,20 @@ class CompareSheetDefaultsTests(unittest.TestCase):
isis4 = next(s for s in parsed if sheet_key(s) == "isis_adjacency.ipv4")
self.assertTrue(str(isis4.get("title") or "").strip())
def test_filter_enabled_sheets_empty_means_all(self) -> None:
from netx_api.biz_state.compare_service import _filter_enabled_sheets, sheet_key
sheets = [
{"sheet_id": "isis_adjacency.ipv4", "metric_id": "isis_adjacency"},
{"sheet_id": "isis_adjacency.ipv6", "metric_id": "isis_adjacency"},
{"sheet_id": "arp", "metric_id": "arp"},
]
self.assertEqual(len(_filter_enabled_sheets(sheets, None)), 3)
self.assertEqual(len(_filter_enabled_sheets(sheets, [])), 3)
only = _filter_enabled_sheets(sheets, ["isis_adjacency.ipv4", "arp"])
self.assertEqual([sheet_key(s) for s in only], ["isis_adjacency.ipv4", "arp"])
self.assertEqual(_filter_enabled_sheets(sheets, ["missing"]), [])
def test_arp_default_sheet_has_row_filters(self) -> None:
from netx_api.biz_state.compare_service import _default_sheet_for_metric

View file

@ -189,6 +189,7 @@ const en = {
colConnect: "Connect",
start: "Start schedule",
pause: "Pause",
statusPaused: "Paused",
scheduleEnabled: "Enable schedule",
scheduleOn: "Scheduled",
scheduleOff: "Manual",
@ -198,7 +199,7 @@ const en = {
confirmDelete: "Delete this task and all batches?",
deleted: "Deleted",
started: "Schedule enabled",
paused: "Schedule off (manual only)",
paused: "Schedule paused (manual only)",
collecting: "Collecting",
detail: "Details",
interval: "Interval",
@ -293,6 +294,10 @@ const en = {
templateDeleted: "Template deleted",
confirmDeleteTemplate: "Delete this compare template?",
confirmDeleteJob: "Delete this compare job and all runs?",
confirmDeleteRun: "Delete this compare run? Job config is kept.",
deleteJob: "Delete job",
deleteRun: "Delete run",
runDeleted: "Compare run deleted",
emptyTemplates: "No templates yet. Create one or import a JSON file.",
exportTemplate: "Export",
importTemplate: "Import",
@ -406,6 +411,21 @@ const en = {
sameAsBefore: "Same as before / select…",
autoHint: "auto: new after-task batches trigger compare",
createCompare: "Create job",
createSteps: "Create steps",
stepBasics: "Basics",
stepSheets: "Sheets",
stepBatches: "Batches",
stepMapping: "Port mapping",
next: "Next",
back: "Back",
enabledSheets: "Compare sheets",
enabledSheetsHint: "Toggle sheets; all on by default. Off sheets are skipped.",
noTemplateSheets: "This template has no sheets",
needJobName: "Enter a job name",
needTemplate: "Select a compare template",
needSheets: "Enable at least one sheet",
needBeforeBatch: "Select before task and batch",
needAfterBatch: "Select after batch",
runNow: "Run now",
saveJob: "Save config",
jobSaved: "Job config saved",
@ -414,13 +434,14 @@ const en = {
detail: "Details",
edit: "Edit",
delete: "Delete",
tabConfig: "Config / batches",
tabConfig: "Job config",
tabResult: "Result",
runs: "Run history",
noRuns: "No runs yet — run a compare first",
result: "Result",
pickBatchRun: "Select compare batch",
pickRun: "Select batch…",
pickBatchRun: "Select compare run",
pickRun: "Select run…",
runCount: "{{n}} runs",
resultEmpty: "No matching diff rows",
resultFilterPh: "Filter key / values…",
kindAll: "All",

View file

@ -189,6 +189,7 @@ const zh = {
colConnect: "连通",
start: "启动周期",
pause: "暂停",
statusPaused: "已暂停",
scheduleEnabled: "启用周期调度",
scheduleOn: "周期",
scheduleOff: "手动",
@ -198,7 +199,7 @@ const zh = {
confirmDelete: "删除该业务监控任务及所有批次?",
deleted: "已删除",
started: "已启用周期采集",
paused: "已关闭周期(仅手动采集)",
paused: "已暂停周期(仅手动采集)",
collecting: "采集中",
detail: "详情",
interval: "采集周期",
@ -292,7 +293,11 @@ const zh = {
templateSaved: "模板已保存",
templateDeleted: "模板已删除",
confirmDeleteTemplate: "删除该比对模板?",
confirmDeleteJob: "删除该比对任务及全部运行记录?",
confirmDeleteJob: "删除该比对任务及全部比对记录?",
confirmDeleteRun: "删除本次比对结果?任务配置将保留。",
deleteJob: "删除任务",
deleteRun: "删除本次",
runDeleted: "比对记录已删除",
emptyTemplates: "暂无模板。可新建,或从 JSON 文件导入。",
exportTemplate: "导出",
importTemplate: "导入",
@ -405,6 +410,21 @@ const zh = {
sameAsBefore: "同操作前 / 选择…",
autoHint: "auto:操作后任务新批次到达会自动跑比对",
createCompare: "创建比对任务",
createSteps: "创建步骤",
stepBasics: "基本信息",
stepSheets: "对比项",
stepBatches: "批次",
stepMapping: "端口映射",
next: "下一步",
back: "上一步",
enabledSheets: "对比项",
enabledSheetsHint: "点击开关;默认全部开启。关闭的项不参与本次比对。",
noTemplateSheets: "该模板暂无对比表",
needJobName: "请填写任务名称",
needTemplate: "请选择比对模板",
needSheets: "请至少开启一项对比",
needBeforeBatch: "请选择操作前任务与批次",
needAfterBatch: "请选择操作后批次",
runNow: "立即比对",
saveJob: "保存配置",
jobSaved: "任务配置已保存",
@ -413,13 +433,14 @@ const zh = {
detail: "详情",
edit: "编辑",
delete: "删除",
tabConfig: "配置 / 批次",
tabConfig: "任务配置",
tabResult: "比对结果",
runs: "历史 Run",
runs: "历史比对",
noRuns: "尚无比对记录,请先执行比对",
result: "比对结果",
pickBatchRun: "选择比对批次",
pickRun: "选择批次…",
pickBatchRun: "选择比对记录",
pickRun: "选择比对记录…",
runCount: "{{n}} 次",
resultEmpty: "无匹配失败/结果行",
resultFilterPh: "筛选身份 / 字段值…",
kindAll: "全部",

View file

@ -10821,6 +10821,17 @@ html.login-page--paused .login-page__flare {
max-height: min(55vh, 520px);
overflow: auto;
}
.ct-field-table {
border-collapse: separate;
border-spacing: 0;
}
.ct-field-table thead th {
position: sticky;
top: 0;
z-index: 2;
background: rgba(15, 23, 42, 0.98) !important;
box-shadow: 0 1px 0 rgba(148, 163, 184, 0.22);
}
.ct-field-table th,
.ct-field-table td {
vertical-align: middle;
@ -11314,13 +11325,19 @@ html.login-page--paused .login-page__flare {
}
.bs-cmp-board__run-select {
flex: 0 1 280px;
flex: 0 1 360px;
min-width: 160px;
max-width: 320px;
max-width: 420px;
height: 32px;
margin: 0;
}
.bs-cmp-board__run-count {
flex: 0 0 auto;
font-size: 12px;
white-space: nowrap;
}
.bs-cmp-board__actions {
margin-left: auto;
flex: 0 0 auto;

View file

@ -11,6 +11,7 @@ import {
bizCompareCreateMapping,
bizCompareCreateTemplate,
bizCompareDeleteJob,
bizCompareDeleteRun,
bizCompareDeleteTemplate,
bizCompareDownloadRun,
bizCompareGetRun,
@ -35,6 +36,8 @@ import { jobChipColor, NmStatusChip } from "./nmChips";
type PageTab = "templates" | "jobs";
type JobDetailTab = "config" | "result";
type KindFilter = "diff" | "all" | "added" | "removed" | "changed" | "unchanged";
type CreateJobStep = 0 | 1 | 2 | 3;
const CREATE_JOB_STEPS = 4;
type TaskOpt = { id: string; ne_name: string; ne_ip: string; vendor: string };
type BatchOpt = { id: string; status: string; row_count: number; started_at?: string | null };
@ -101,6 +104,7 @@ type Job = {
after_batch_id: string;
mode: string;
status: string;
enabled_sheet_ids?: string[];
note?: string;
};
@ -774,10 +778,12 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
// job create / detail
const [jobCreateOpen, setJobCreateOpen] = useState(false);
const [createStep, setCreateStep] = useState<CreateJobStep>(0);
const [jobId, setJobId] = useState("");
const [jobDetailTab, setJobDetailTab] = useState<JobDetailTab>("config");
const [name, setName] = useState("");
const [templateId, setTemplateId] = useState("");
const [enabledSheetIds, setEnabledSheetIds] = useState<string[]>([]);
const [mappingId, setMappingId] = useState("");
const [beforeTaskId, setBeforeTaskId] = useState("");
const [afterTaskId, setAfterTaskId] = useState("");
@ -873,6 +879,66 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
});
}, [templates, debouncedListKw]);
const selectedJobTemplate = useMemo(
() => templates.find((x) => x.id === templateId) || null,
[templates, templateId],
);
const jobTemplateSheets = useMemo(
() => (selectedJobTemplate ? templateSheets(selectedJobTemplate) : []),
[selectedJobTemplate],
);
const jobSheetAllIds = useMemo(
() => jobTemplateSheets.map((s) => sheetIdentity(s)).filter(Boolean),
[jobTemplateSheets],
);
const isJobSheetOn = useCallback(
(sid: string) => {
if (!enabledSheetIds.length) return true;
return enabledSheetIds.includes(sid);
},
[enabledSheetIds],
);
const enabledJobSheetCount = useMemo(() => {
if (!jobSheetAllIds.length) return 0;
if (!enabledSheetIds.length) return jobSheetAllIds.length;
return jobSheetAllIds.filter((id) => enabledSheetIds.includes(id)).length;
}, [jobSheetAllIds, enabledSheetIds]);
const toggleJobSheet = useCallback(
(sid: string) => {
const all = jobSheetAllIds;
if (!all.length) return;
const currentlyOn = !enabledSheetIds.length
? [...all]
: enabledSheetIds.filter((id) => all.includes(id));
const next = currentlyOn.includes(sid)
? currentlyOn.filter((id) => id !== sid)
: [...currentlyOn, sid];
// Empty list means "all on" (new template sheets auto-included)
setEnabledSheetIds(next.length === all.length ? [] : next);
},
[jobSheetAllIds, enabledSheetIds],
);
const setJobTemplateAndSheets = useCallback(
(nextTplId: string, presetIds?: string[] | null) => {
setTemplateId(nextTplId);
const tpl = templates.find((x) => x.id === nextTplId);
const all = tpl ? templateSheets(tpl).map((s) => sheetIdentity(s)).filter(Boolean) : [];
if (presetIds && presetIds.length) {
const kept = presetIds.filter((id) => all.includes(id));
setEnabledSheetIds(kept.length === all.length ? [] : kept);
} else {
setEnabledSheetIds([]);
}
},
[templates],
);
const activeTplSheet = tplSheets[tplSheetIdx] || null;
const activeTplFields = useMemo(() => {
if (!activeTplSheet) return [];
@ -1444,8 +1510,18 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
};
const resetJobForm = (preset?: Partial<Job>) => {
const tplId = preset?.template_id || templates[0]?.id || "";
setName(preset?.name || t("bizCompare.defaultJobName"));
setTemplateId(preset?.template_id || templates[0]?.id || "");
setTemplateId(tplId);
const tpl = templates.find((x) => x.id === tplId);
const all = tpl ? templateSheets(tpl).map((s) => sheetIdentity(s)).filter(Boolean) : [];
const presetIds = (preset?.enabled_sheet_ids || []).map(String).filter(Boolean);
if (presetIds.length) {
const kept = presetIds.filter((id) => all.includes(id));
setEnabledSheetIds(kept.length === all.length ? [] : kept);
} else {
setEnabledSheetIds([]);
}
setMappingId(preset?.mapping_id || "");
setBeforeTaskId(preset?.before_task_id || "");
setAfterTaskId(preset?.after_task_id || "");
@ -1460,26 +1536,85 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
}
};
const jobConfigBody = () => ({
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,
enabled_sheet_ids: enabledSheetIds,
});
const closeCreateJob = () => {
setJobCreateOpen(false);
setCreateStep(0);
};
const openCreateJob = () => {
resetJobForm();
setCreateStep(0);
setJobCreateOpen(true);
};
const canAdvanceCreateStep = (step: CreateJobStep): boolean => {
if (step === 0) return Boolean(name.trim() && templateId);
if (step === 1) return enabledJobSheetCount > 0;
if (step === 2) {
if (!beforeTaskId || !beforeBatchId) return false;
if (mode === "manual" && !afterBatchId) return false;
return true;
}
return true;
};
const createStepBlockReason = (step: CreateJobStep): string | null => {
if (step === 0) {
if (!name.trim()) return t("bizCompare.needJobName");
if (!templateId) return t("bizCompare.needTemplate");
return null;
}
if (step === 1) {
if (!enabledJobSheetCount) return t("bizCompare.needSheets");
return null;
}
if (step === 2) {
if (!beforeTaskId || !beforeBatchId) return t("bizCompare.needBeforeBatch");
if (mode === "manual" && !afterBatchId) return t("bizCompare.needAfterBatch");
return null;
}
return null;
};
const onCreateNext = () => {
const reason = createStepBlockReason(createStep);
if (reason) {
showError(reason);
return;
}
setCreateStep((s) => Math.min(CREATE_JOB_STEPS - 1, (s + 1) as CreateJobStep) as CreateJobStep);
};
const onCreateBack = () => {
setCreateStep((s) => Math.max(0, s - 1) as CreateJobStep);
};
const createJob = async () => {
const reason =
createStepBlockReason(0) ||
createStepBlockReason(1) ||
createStepBlockReason(2);
if (reason) {
showError(reason);
return;
}
setBusy(true);
try {
const j = await bizCompareCreateJob({
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,
});
const j = await bizCompareCreateJob(jobConfigBody());
showOk(t("bizCompare.created"));
setJobCreateOpen(false);
closeCreateJob();
await refresh();
await openJob(String(j.id));
} catch (e) {
@ -1520,18 +1655,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const saveJobConfig = async () => {
if (!jobId) return;
if (!enabledJobSheetCount) {
showError(t("bizCompare.needSheets"));
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,
});
await bizCompareUpdateJob(jobId, jobConfigBody());
showOk(t("bizCompare.jobSaved"));
await refresh();
} catch (e) {
@ -1543,15 +1673,13 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
const runNow = async () => {
if (!jobId) return;
if (!enabledJobSheetCount) {
showError(t("bizCompare.needSheets"));
return;
}
setBusy(true);
try {
await bizCompareUpdateJob(jobId, {
before_batch_id: beforeBatchId,
after_batch_id: mode === "manual" ? afterBatchId : "",
mode,
mapping_id: mappingId,
template_id: templateId,
});
await bizCompareUpdateJob(jobId, jobConfigBody());
const run = await bizCompareRunJob(jobId);
setRunDetail(run);
setJobDetailTab("result");
@ -1578,36 +1706,120 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
}
};
const renderJobForm = (compact = false) => (
<div className="bs-cmp-form" style={{ display: "grid", gap: 8 }}>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.jobName")}</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<FieldSelect
label={t("bizCompare.template")}
value={templateId}
onChange={(e) => setTemplateId(e.target.value)}
fullWidth
>
{templates.map((tpl) => {
const mids = tpl.metric_ids || templateSheets(tpl).map((s) => s.metric_id);
const removeRun = async (runId: string) => {
if (!runId) return;
if (!window.confirm(t("bizCompare.confirmDeleteRun"))) return;
setBusy(true);
try {
const wasCurrent = String(runDetail?.id || "") === runId;
await bizCompareDeleteRun(runId);
let nextRuns: typeof runs = [];
if (jobId) {
const r = await bizCompareListRuns(jobId);
nextRuns = r.items || [];
} else {
nextRuns = (runs || []).filter((r) => String(r.id) !== runId);
}
setRuns(nextRuns);
if (wasCurrent) {
if (nextRuns.length) {
await loadRun(String(nextRuns[0].id));
} else {
setRunDetail(null);
setResultSheetId("");
}
}
showOk(t("bizCompare.runDeleted"));
} catch (e) {
showError(formatErr(e));
} finally {
setBusy(false);
}
};
const renderSheetChips = () => (
<div className="bm-create__pane" style={{ padding: 0, border: "none", background: "transparent" }}>
<div className="bm-mapping__label">
{t("bizCompare.enabledSheets")}
<span className="muted" style={{ fontWeight: 400, marginLeft: 8 }}>
{enabledJobSheetCount}/{jobSheetAllIds.length || 0}
</span>
</div>
<p className="muted bm-hint">{t("bizCompare.enabledSheetsHint")}</p>
<div className="bm-metric-chips">
{jobTemplateSheets.map((s) => {
const sid = sheetIdentity(s);
const on = isJobSheetOn(sid);
return (
<option key={tpl.id} value={tpl.id}>
{tpl.name} ({mids.length} {t("bizCompare.sheetsUnit")})
</option>
<button
key={sid}
type="button"
className={`bm-metric-chip${on ? " is-on" : ""}`}
onClick={() => toggleJobSheet(sid)}
title={s.metric_id !== sid ? s.metric_id : undefined}
>
{sheetLabel(s)}
</button>
);
})}
</FieldSelect>
<FieldSelect
label={t("bizCompare.mode")}
value={mode}
onChange={(e) => setMode(e.target.value as "manual" | "auto")}
fullWidth
>
<option value="manual">{t("bizCompare.modeManual")}</option>
<option value="auto">{t("bizCompare.modeAuto")}</option>
</FieldSelect>
</div>
{!jobTemplateSheets.length ? (
<p className="muted bm-hint">{t("bizCompare.noTemplateSheets")}</p>
) : null}
{jobTemplateSheets.length && !enabledJobSheetCount ? (
<p className="form-error bm-hint">{t("bizCompare.needSheets")}</p>
) : null}
</div>
);
const renderMappingBlock = () => (
<div className="bs-cmp-mapping">
<h4 style={{ margin: "8px 0" }}>{t("bizCompare.mapping")}</h4>
<p className="muted">{t("bizCompare.mappingOptionalHint")}</p>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<FieldSelect
value={mappingId}
onChange={(e) => {
const id = e.target.value;
if (id) loadMappingText(id);
else {
setMappingId("");
setMapText("");
}
}}
>
<option value="">{t("bizCompare.newMapping")}</option>
{mappings.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</FieldSelect>
<Input value={mapName} placeholder={t("bizCompare.mapName")} onChange={(e) => setMapName(e.target.value)} />
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void saveMapping()}>
{t("bizCompare.saveMapping")}
</Button>
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void doValidate()}>
{t("bizCompare.validateMapping")}
</Button>
</div>
<textarea
value={mapText}
onChange={(e) => setMapText(e.target.value)}
placeholder={t("bizCompare.mapHint")}
rows={5}
style={{ width: "100%", fontFamily: "ui-monospace, monospace" }}
/>
{validateOut ? (
<pre className="muted" style={{ fontSize: 12, maxHeight: 120, overflow: "auto" }}>
{JSON.stringify(validateOut, null, 2)}
</pre>
) : null}
</div>
);
const renderBatchFields = () => (
<>
<FieldSelect
label={t("bizCompare.beforeTask")}
value={beforeTaskId}
@ -1664,52 +1876,42 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
) : (
<p className="muted">{t("bizCompare.autoHint")}</p>
)}
</>
);
{!compact ? (
<div className="bs-cmp-mapping">
<h4 style={{ margin: "8px 0" }}>{t("bizCompare.mapping")}</h4>
<p className="muted">{t("bizCompare.mappingOptionalHint")}</p>
<div className="filter-inline" style={{ marginBottom: 8 }}>
<FieldSelect
value={mappingId}
onChange={(e) => {
const id = e.target.value;
if (id) loadMappingText(id);
else {
setMappingId("");
setMapText("");
}
}}
>
<option value="">{t("bizCompare.newMapping")}</option>
{mappings.map((m) => (
<option key={m.id} value={m.id}>
{m.name}
</option>
))}
</FieldSelect>
<Input value={mapName} placeholder={t("bizCompare.mapName")} onChange={(e) => setMapName(e.target.value)} />
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void saveMapping()}>
{t("bizCompare.saveMapping")}
</Button>
<Button size="sm" variant="secondary" isDisabled={busy} onPress={() => void doValidate()}>
{t("bizCompare.validateMapping")}
</Button>
</div>
<textarea
value={mapText}
onChange={(e) => setMapText(e.target.value)}
placeholder={t("bizCompare.mapHint")}
rows={5}
style={{ width: "100%", fontFamily: "ui-monospace, monospace" }}
/>
{validateOut ? (
<pre className="muted" style={{ fontSize: 12, maxHeight: 120, overflow: "auto" }}>
{JSON.stringify(validateOut, null, 2)}
</pre>
) : null}
</div>
) : null}
const renderJobForm = () => (
<div className="bs-cmp-form" style={{ display: "grid", gap: 8 }}>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.jobName")}</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<FieldSelect
label={t("bizCompare.template")}
value={templateId}
onChange={(e) => setJobTemplateAndSheets(e.target.value)}
fullWidth
>
{templates.map((tpl) => {
const n = templateSheets(tpl).length;
return (
<option key={tpl.id} value={tpl.id}>
{tpl.name} ({n} {t("bizCompare.sheetsUnit")})
</option>
);
})}
</FieldSelect>
<FieldSelect
label={t("bizCompare.mode")}
value={mode}
onChange={(e) => setMode(e.target.value as "manual" | "auto")}
fullWidth
>
<option value="manual">{t("bizCompare.modeManual")}</option>
<option value="auto">{t("bizCompare.modeAuto")}</option>
</FieldSelect>
{renderSheetChips()}
{renderBatchFields()}
{renderMappingBlock()}
</div>
);
@ -1896,7 +2098,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
isDisabled={busy}
onPress={() => void removeJob(j.id)}
>
{t("bizCompare.delete")}
{t("bizCompare.deleteJob")}
</Button>
</div>
</td>
@ -2101,21 +2303,22 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
onChange={(next) => updateActiveSheet({ row_filters: fromOrGroups(next) })}
/>
) : (
<div className="pt-list-table-wrap ct-field-table-wrap">
<>
<p className="muted ct-field-hint">{t("bizCompare.fieldsHintShort")}</p>
<table className="data-table pt-list-table ct-field-table">
<thead>
<tr>
<th>{t("bizCompare.field")}</th>
<th title={t("bizCompare.keyFields")}>{t("bizCompare.keyFields")}</th>
<th title={t("bizCompare.ifaceFields")}>{t("bizCompare.ifaceFields")}</th>
<th title={t("bizCompare.compareFields")}>{t("bizCompare.compareFields")}</th>
<th>{t("bizCompare.compareMode")}</th>
<th>{t("bizCompare.tolerance")}</th>
<th>{t("bizCompare.displayField")}</th>
<th>{t("bizCompare.normalizeField")}</th>
</tr>
</thead>
<div className="pt-list-table-wrap ct-field-table-wrap">
<table className="data-table pt-list-table ct-field-table">
<thead>
<tr>
<th>{t("bizCompare.field")}</th>
<th title={t("bizCompare.keyFields")}>{t("bizCompare.keyFields")}</th>
<th title={t("bizCompare.ifaceFields")}>{t("bizCompare.ifaceFields")}</th>
<th title={t("bizCompare.compareFields")}>{t("bizCompare.compareFields")}</th>
<th>{t("bizCompare.compareMode")}</th>
<th>{t("bizCompare.tolerance")}</th>
<th>{t("bizCompare.displayField")}</th>
<th>{t("bizCompare.normalizeField")}</th>
</tr>
</thead>
<tbody>
{activeTplFields.map((f) => {
const isKey = activeTplSheet.key_fields.includes(f.name);
@ -2266,16 +2469,17 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</tr>
);
})}
{!activeTplFields.length ? (
<tr>
<td colSpan={8}>
<div className="pt-list-empty">{t("bizCompare.noMetricFields")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
{!activeTplFields.length ? (
<tr>
<td colSpan={8}>
<div className="pt-list-empty">{t("bizCompare.noMetricFields")}</div>
</td>
</tr>
) : null}
</tbody>
</table>
</div>
</>
)}
</div>
) : null}
@ -2292,20 +2496,118 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</Modal.Footer>
</AppModalShell>
{/* Create job */}
<AppModalShell open={jobCreateOpen} onClose={() => setJobCreateOpen(false)} size="lg">
{/* Create job — guided steps */}
<AppModalShell
open={jobCreateOpen}
onClose={closeCreateJob}
size="lg"
className="bm-create-modal"
>
<Modal.Header>
<Modal.Heading>{t("bizCompare.createCompare")}</Modal.Heading>
<Modal.CloseTrigger />
</Modal.Header>
<Modal.Body className="flex flex-col gap-3">{renderJobForm(true)}</Modal.Body>
<Modal.Body className="flex flex-col gap-3 bm-create">
<nav className="bm-steps" aria-label={t("bizCompare.createSteps")}>
{(
[
t("bizCompare.stepBasics"),
t("bizCompare.stepSheets"),
t("bizCompare.stepBatches"),
t("bizCompare.stepMapping"),
] as const
).map((label, i) => {
const done = i < createStep;
const active = i === createStep;
return (
<button
key={label}
type="button"
className={`bm-steps__item${active ? " is-active" : ""}${done ? " is-done" : ""}`}
onClick={() => {
if (i <= createStep) {
setCreateStep(i as CreateJobStep);
return;
}
let ok = true;
for (let s = 0; s < i; s++) {
if (!canAdvanceCreateStep(s as CreateJobStep)) {
ok = false;
break;
}
}
if (ok) setCreateStep(i as CreateJobStep);
}}
>
<span className="bm-steps__num">{done ? "✓" : i + 1}</span>
<span className="bm-steps__label">{label}</span>
</button>
);
})}
</nav>
{createStep === 0 ? (
<div className="bm-create__pane bs-cmp-form" style={{ display: "grid", gap: 8 }}>
<label className="ui-field ui-field--full">
<span className="ui-field__label">{t("bizCompare.jobName")}</span>
<Input value={name} onChange={(e) => setName(e.target.value)} />
</label>
<FieldSelect
label={t("bizCompare.template")}
value={templateId}
onChange={(e) => setJobTemplateAndSheets(e.target.value)}
fullWidth
>
{templates.map((tpl) => {
const n = templateSheets(tpl).length;
return (
<option key={tpl.id} value={tpl.id}>
{tpl.name} ({n} {t("bizCompare.sheetsUnit")})
</option>
);
})}
</FieldSelect>
<FieldSelect
label={t("bizCompare.mode")}
value={mode}
onChange={(e) => setMode(e.target.value as "manual" | "auto")}
fullWidth
>
<option value="manual">{t("bizCompare.modeManual")}</option>
<option value="auto">{t("bizCompare.modeAuto")}</option>
</FieldSelect>
</div>
) : null}
{createStep === 1 ? <div className="bm-create__pane">{renderSheetChips()}</div> : null}
{createStep === 2 ? (
<div className="bm-create__pane bs-cmp-form" style={{ display: "grid", gap: 8 }}>
{renderBatchFields()}
</div>
) : null}
{createStep === 3 ? <div className="bm-create__pane">{renderMappingBlock()}</div> : null}
</Modal.Body>
<Modal.Footer>
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void createJob()}>
{t("bizCompare.createCompare")}
</Button>
<Button size="sm" variant="ghost" onPress={() => setJobCreateOpen(false)}>
<Button size="sm" variant="secondary" onPress={closeCreateJob}>
{t("bizState.cancel")}
</Button>
<div className="bm-create__footer-spacer" />
{createStep > 0 ? (
<Button size="sm" variant="secondary" onPress={onCreateBack}>
{t("bizCompare.back")}
</Button>
) : null}
{createStep < CREATE_JOB_STEPS - 1 ? (
<Button size="sm" variant="primary" onPress={onCreateNext}>
{t("bizCompare.next")}
</Button>
) : (
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void createJob()}>
{t("bizCompare.createCompare")}
</Button>
)}
</Modal.Footer>
</AppModalShell>
@ -2343,7 +2645,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
</div>
{jobDetailTab === "config" ? (
renderJobForm(false)
renderJobForm()
) : (
<div
ref={boardRef}
@ -2375,13 +2677,20 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
);
const bl = sideDeviceName(before);
const al = sideDeviceName(after);
const when = formatSystemTime((r as any).created_at) || "";
return (
<option key={r.id} value={r.id}>
{when ? `${when} · ` : ""}
{bl} {sideCollectTime(before)} → {al} {sideCollectTime(after)}
</option>
);
})}
</select>
{runs.length ? (
<span className="muted bs-cmp-board__run-count">
{t("bizCompare.runCount", { n: String(runs.length) })}
</span>
) : null}
{runDetail ? (
<div className="bs-cmp-sides" aria-label={t("bizCompare.sidesTitle")}>
{(() => {
@ -2438,6 +2747,14 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
>
{t("bizCompare.exportTables")}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={busy || !runDetail?.id}
onPress={() => void removeRun(String(runDetail?.id || ""))}
>
{t("bizCompare.deleteRun")}
</Button>
<Button
size="sm"
variant="secondary"
@ -2782,6 +3099,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void runNow()}>
{t("bizCompare.runNow")}
</Button>
{jobId ? (
<Button size="sm" variant="danger" isDisabled={busy} onPress={() => void removeJob(jobId)}>
{t("bizCompare.deleteJob")}
</Button>
) : null}
</>
) : (
<>
@ -2793,6 +3115,14 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
>
{t("bizCompare.exportTables")}
</Button>
<Button
size="sm"
variant="danger"
isDisabled={busy || !runDetail?.id}
onPress={() => void removeRun(String(runDetail?.id || ""))}
>
{t("bizCompare.deleteRun")}
</Button>
<Button size="sm" variant="primary" isDisabled={busy} onPress={() => void runNow()}>
{t("bizCompare.runNow")}
</Button>
@ -2801,11 +3131,6 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
<Button size="sm" variant="ghost" onPress={closeJob}>
{t("bizState.cancel")}
</Button>
{jobId ? (
<Button size="sm" variant="danger" isDisabled={busy} onPress={() => void removeJob(jobId)}>
{t("bizCompare.delete")}
</Button>
) : null}
</Modal.Footer>
</AppModalShell>
</section>

View file

@ -22,9 +22,11 @@ import {
bizStateListProfiles,
bizStateListTasks,
bizStatePatchTask,
bizStatePauseTask,
bizStatePurgeTask,
bizStateSetBatchBaseline,
bizStateSetBindings,
bizStateStartTask,
fetchCliTargets,
formatErr,
} from "../../services/api";
@ -538,7 +540,11 @@ export function BizStatePage() {
const setScheduleEnabled = async (id: string, enabled: boolean) => {
setBusy(true);
try {
await bizStatePatchTask(id, { status: enabled ? "running" : "paused" });
if (enabled) {
await bizStateStartTask(id);
} else {
await bizStatePauseTask(id);
}
showOk(enabled ? t("bizState.started") : t("bizState.paused"));
if (taskId === id) await loadTask(id);
await refreshTasks();
@ -981,10 +987,12 @@ export function BizStatePage() {
<div className="pt-list-actions" style={{ flexWrap: "wrap", gap: 4 }}>
{row.collect_running ? (
<NmStatusChip color="accent">{t("bizState.collecting")}</NmStatusChip>
) : row.status === "running" ? (
<NmStatusChip color="success">{t("bizState.scheduleOn")}</NmStatusChip>
) : row.status === "paused" ? (
<NmStatusChip color="warning">{t("bizState.statusPaused")}</NmStatusChip>
) : (
<NmStatusChip color={row.status === "running" ? "success" : "default"}>
{row.status === "running" ? t("bizState.scheduleOn") : t("bizState.scheduleOff")}
</NmStatusChip>
<NmStatusChip color="default">{t("bizState.scheduleOff")}</NmStatusChip>
)}
</div>
</td>
@ -997,6 +1005,25 @@ export function BizStatePage() {
<Button size="sm" variant="ghost" onPress={() => void openTask(row.id, "batches")}>
{t("bizState.batches")}
</Button>
{row.status === "running" ? (
<Button
size="sm"
variant="ghost"
isDisabled={busy}
onPress={() => void setScheduleEnabled(row.id, false)}
>
{t("bizState.pause")}
</Button>
) : (
<Button
size="sm"
variant="ghost"
isDisabled={busy}
onPress={() => void setScheduleEnabled(row.id, true)}
>
{t("bizState.start")}
</Button>
)}
<Button
size="sm"
variant="secondary"
@ -1161,6 +1188,25 @@ export function BizStatePage() {
/>
<span>{t("bizState.scheduleEnabled")}</span>
</label>
{detail.status === "running" ? (
<Button
size="sm"
variant="ghost"
isDisabled={busy}
onPress={() => void setScheduleEnabled(taskId, false)}
>
{t("bizState.pause")}
</Button>
) : (
<Button
size="sm"
variant="ghost"
isDisabled={busy}
onPress={() => void setScheduleEnabled(taskId, true)}
>
{t("bizState.start")}
</Button>
)}
<label className="config-sync-policy-field">
<span>{t("bizState.interval")}</span>
<Input

View file

@ -1804,6 +1804,12 @@ export const bizStateGetTask = (taskId: string) =>
export const bizStatePatchTask = (taskId: string, body: Record<string, unknown>) =>
apiPatch<Record<string, unknown>>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`, body);
export const bizStatePauseTask = (taskId: string) =>
apiPost<Record<string, unknown>>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/pause`, {});
export const bizStateStartTask = (taskId: string) =>
apiPost<Record<string, unknown>>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}/start`, {});
export const bizStateDeleteTask = (taskId: string) =>
apiDelete<{ ok: boolean }>(`/v1/biz-state/tasks/${encodeURIComponent(taskId)}`);
@ -1985,6 +1991,11 @@ export const bizCompareListRuns = (jobId: string, limit = 20) =>
export const bizCompareGetRun = (runId: string) =>
apiGet<Record<string, unknown>>(`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`);
export const bizCompareDeleteRun = (runId: string) =>
apiDelete<{ ok: boolean; job_id?: string; run_id?: string }>(
`/v1/biz-state/compare/runs/${encodeURIComponent(runId)}`,
);
export const bizCompareListRunDiffs = (params: {
runId: string;
metricId?: string;