Align biz-state interval to days/hours and polish compare result workbook.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
oliver 2026-09-17 18:57:25 +08:00
parent 29b08f9807
commit ff37d0866a
7 changed files with 411 additions and 146 deletions

View file

@ -154,7 +154,7 @@ def create_task(db: Session, body: dict[str, Any]) -> dict[str, Any]:
device_type=device_type, device_type=device_type,
note=str(body.get("note") or "")[:256], note=str(body.get("note") or "")[:256],
status="draft", status="draft",
interval_sec=max(60, int(body.get("interval_sec") or 300)), interval_sec=max(3600, int(body.get("interval_sec") or 3600)),
retention_batches=max(1, int(body.get("retention_batches") or 30)), retention_batches=max(1, int(body.get("retention_batches") or 30)),
created_at=_utcnow(), created_at=_utcnow(),
updated_at=_utcnow(), updated_at=_utcnow(),
@ -228,7 +228,7 @@ def update_task(db: Session, task_id: str, body: dict[str, Any]) -> dict[str, An
if "note" in body: if "note" in body:
task.note = str(body.get("note") or "")[:256] task.note = str(body.get("note") or "")[:256]
if "interval_sec" in body: if "interval_sec" in body:
task.interval_sec = max(60, int(body.get("interval_sec") or 300)) task.interval_sec = max(3600, int(body.get("interval_sec") or 3600))
if "retention_batches" in body: if "retention_batches" in body:
task.retention_batches = max(1, int(body.get("retention_batches") or 30)) task.retention_batches = max(1, int(body.get("retention_batches") or 30))
if "items" in body: if "items" in body:

View file

@ -44,7 +44,7 @@ class TaskCreateIn(BaseModel):
vendor: str = "" vendor: str = ""
device_type: str = "" device_type: str = ""
note: str = "" note: str = ""
interval_sec: int = 300 interval_sec: int = 3600
retention_batches: int = 30 retention_batches: int = 30
items: list[TaskItemIn] = Field(default_factory=list) items: list[TaskItemIn] = Field(default_factory=list)

View file

@ -191,7 +191,9 @@ const en = {
paused: "Paused", paused: "Paused",
collecting: "Collecting", collecting: "Collecting",
detail: "Details", detail: "Details",
interval: "Interval (sec)", interval: "Interval",
intervalUnitDays: "days",
intervalUnitHours: "hours",
retention: "Keep batches", retention: "Keep batches",
saveSchedule: "Save schedule", saveSchedule: "Save schedule",
scheduleSaved: "Schedule saved", scheduleSaved: "Schedule saved",
@ -306,11 +308,17 @@ const en = {
pickRun: "Select batch…", pickRun: "Select batch…",
resultEmpty: "No matching diff rows", resultEmpty: "No matching diff rows",
resultFilterPh: "Filter key / values…", resultFilterPh: "Filter key / values…",
kindAll: "All kinds", kindAll: "All",
kindDiff: "Diffs only",
kindAdded: "Added only", kindAdded: "Added only",
kindRemoved: "Removed only", kindRemoved: "Removed only",
kindChanged: "Changed only", kindChanged: "Changed only",
kindUnchanged: "Unchanged only", kindUnchanged: "Unchanged only",
kindAddedShort: "Added",
kindRemovedShort: "Removed",
kindChangedShort: "Changed",
kindUnchangedShort: "Same",
diffCount: "{{n}} diffs",
emptyJobs: "No compare jobs yet", emptyJobs: "No compare jobs yet",
created: "Compare job created", created: "Compare job created",
ran: "Compare finished", ran: "Compare finished",

View file

@ -191,7 +191,9 @@ const zh = {
paused: "已暂停", paused: "已暂停",
collecting: "采集中", collecting: "采集中",
detail: "详情", detail: "详情",
interval: "采集周期(秒)", interval: "采集周期",
intervalUnitDays: "天",
intervalUnitHours: "小时",
retention: "保留批次数", retention: "保留批次数",
saveSchedule: "保存周期", saveSchedule: "保存周期",
scheduleSaved: "周期配置已保存", scheduleSaved: "周期配置已保存",
@ -305,11 +307,17 @@ const zh = {
pickRun: "选择批次…", pickRun: "选择批次…",
resultEmpty: "无匹配差异行", resultEmpty: "无匹配差异行",
resultFilterPh: "筛选 key / 字段值…", resultFilterPh: "筛选 key / 字段值…",
kindAll: "全部类型", kindAll: "全部",
kindDiff: "仅差异",
kindAdded: "仅新增", kindAdded: "仅新增",
kindRemoved: "仅删除", kindRemoved: "仅删除",
kindChanged: "仅变更", kindChanged: "仅变更",
kindUnchanged: "仅未变", kindUnchanged: "仅未变",
kindAddedShort: "新增",
kindRemovedShort: "删除",
kindChangedShort: "变更",
kindUnchangedShort: "未变",
diffCount: "差异 {{n}}",
emptyJobs: "暂无比对任务", emptyJobs: "暂无比对任务",
created: "比对任务已创建", created: "比对任务已创建",
ran: "比对完成", ran: "比对完成",

View file

@ -10020,32 +10020,210 @@ html.login-page--paused .login-page__flare {
min-height: min(64vh, 680px); min-height: min(64vh, 680px);
} }
.bs-cmp-result {
min-height: 0;
}
.bs-cmp-kpis {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 8px;
}
.bs-cmp-kpi {
appearance: none;
border: 1px solid rgba(148, 163, 184, 0.28);
border-radius: 10px;
background: rgba(148, 163, 184, 0.06);
padding: 10px 12px;
text-align: left;
cursor: pointer;
color: inherit;
transition: border-color 0.15s ease, box-shadow 0.15s ease, background 0.15s ease;
}
.bs-cmp-kpi:hover {
border-color: rgba(59, 130, 246, 0.45);
}
.bs-cmp-kpi.is-active {
border-color: rgba(37, 99, 235, 0.65);
box-shadow: 0 0 0 1px rgba(37, 99, 235, 0.2);
background: rgba(59, 130, 246, 0.08);
}
.bs-cmp-kpi__label {
display: block;
font-size: 12px;
opacity: 0.72;
margin-bottom: 4px;
}
.bs-cmp-kpi__value {
font-size: 22px;
font-weight: 700;
font-variant-numeric: tabular-nums;
line-height: 1.1;
}
.bs-cmp-kpi--added .bs-cmp-kpi__value {
color: #16a34a;
}
.bs-cmp-kpi--removed .bs-cmp-kpi__value {
color: #dc2626;
}
.bs-cmp-kpi--changed .bs-cmp-kpi__value {
color: #d97706;
}
.bs-cmp-kpi--unchanged .bs-cmp-kpi__value {
color: #64748b;
}
.bs-cmp-filter-bar {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
}
.bs-cmp-kind-pills {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.bs-cmp-kind-pill {
appearance: none;
border: 1px solid rgba(148, 163, 184, 0.32);
border-radius: 999px;
background: transparent;
color: inherit;
padding: 4px 10px;
font-size: 12px;
cursor: pointer;
line-height: 1.3;
}
.bs-cmp-kind-pill:hover {
background: rgba(148, 163, 184, 0.12);
}
.bs-cmp-kind-pill.is-active {
background: rgba(37, 99, 235, 0.12);
border-color: rgba(37, 99, 235, 0.55);
font-weight: 600;
color: #1d4ed8;
}
.bs-cmp-result-table { .bs-cmp-result-table {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 260px; min-height: 280px;
max-height: min(46vh, 520px); max-height: min(48vh, 560px);
} }
.bs-cmp-diff-table .bs-cmp-pair-head { .bs-cmp-diff-table {
text-align: center; border-collapse: separate;
border-spacing: 0;
} }
.bs-cmp-diff-table .bs-cmp-pre-head, .bs-cmp-diff-table thead th {
.bs-cmp-diff-table .bs-cmp-post-head { position: sticky;
top: 0;
z-index: 1;
background: var(--wb-surface, #f8fafc);
box-shadow: 0 1px 0 rgba(148, 163, 184, 0.35);
font-size: 12px;
white-space: nowrap;
}
.bs-cmp-col-kind {
width: 72px;
white-space: nowrap;
}
.bs-cmp-badge {
display: inline-block;
padding: 2px 8px;
border-radius: 999px;
font-size: 11px; font-size: 11px;
opacity: 0.8; font-weight: 600;
line-height: 1.4;
}
.bs-cmp-badge--added {
background: rgba(34, 197, 94, 0.16);
color: #15803d;
}
.bs-cmp-badge--removed {
background: rgba(239, 68, 68, 0.14);
color: #b91c1c;
}
.bs-cmp-badge--changed {
background: rgba(245, 158, 11, 0.16);
color: #b45309;
}
.bs-cmp-badge--unchanged {
background: rgba(148, 163, 184, 0.18);
color: #475569;
}
.bs-cmp-key-cell {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 12px;
white-space: nowrap;
}
.bs-cmp-val-cell {
font-size: 12px;
vertical-align: middle;
}
.bs-cmp-val-cell--diff {
background: rgba(245, 158, 11, 0.05);
}
.bs-cmp-val {
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
}
.bs-cmp-val--pre {
color: #b91c1c;
text-decoration: line-through;
text-decoration-thickness: 1px;
opacity: 0.9;
}
.bs-cmp-val--post {
color: #15803d;
font-weight: 600;
}
.bs-cmp-val-arrow {
margin: 0 6px;
opacity: 0.45;
font-weight: 600;
} }
.bs-cmp-row--added { .bs-cmp-row--added {
background: rgba(34, 197, 94, 0.08); background: rgba(34, 197, 94, 0.05);
} }
.bs-cmp-row--removed { .bs-cmp-row--removed {
background: rgba(239, 68, 68, 0.08); background: rgba(239, 68, 68, 0.05);
} }
.bs-cmp-row--changed { .bs-cmp-row--changed {
background: rgba(245, 158, 11, 0.06); background: transparent;
}
.bs-cmp-row--unchanged {
opacity: 0.78;
} }
.bs-cmp-cell--pre { .bs-cmp-cell--pre {
@ -10058,6 +10236,12 @@ html.login-page--paused .login-page__flare {
font-weight: 600; font-weight: 600;
} }
@media (max-width: 720px) {
.bs-cmp-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.nm-config-modal__body { .nm-config-modal__body {
flex: 1 1 auto; flex: 1 1 auto;
width: 100%; width: 100%;

View file

@ -30,7 +30,7 @@ import { jobChipColor, NmStatusChip } from "./nmChips";
type PageTab = "templates" | "jobs"; type PageTab = "templates" | "jobs";
type JobDetailTab = "config" | "result"; type JobDetailTab = "config" | "result";
type KindFilter = "all" | "added" | "removed" | "changed" | "unchanged"; type KindFilter = "diff" | "all" | "added" | "removed" | "changed" | "unchanged";
type TaskOpt = { id: string; ne_name: string; ne_ip: string; vendor: string }; 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 BatchOpt = { id: string; status: string; row_count: number; started_at?: string | null };
@ -196,7 +196,7 @@ export function BizComparePage() {
const [resultSheetId, setResultSheetId] = useState(""); const [resultSheetId, setResultSheetId] = useState("");
// result filters // result filters
const [kindFilter, setKindFilter] = useState<KindFilter>("all"); const [kindFilter, setKindFilter] = useState<KindFilter>("diff");
const [resultKw, setResultKw] = useState(""); const [resultKw, setResultKw] = useState("");
const debouncedResultKw = useDebouncedValue(resultKw, 200); const debouncedResultKw = useDebouncedValue(resultKw, 200);
@ -325,7 +325,11 @@ export function BizComparePage() {
const diffs = (activeRunSheet?.diffs || []) as DiffRow[]; const diffs = (activeRunSheet?.diffs || []) as DiffRow[];
const kw = debouncedResultKw.trim().toLowerCase(); const kw = debouncedResultKw.trim().toLowerCase();
return diffs.filter((d) => { return diffs.filter((d) => {
if (kindFilter !== "all" && d.kind !== kindFilter) return false; if (kindFilter === "diff") {
if (d.kind === "unchanged") return false;
} else if (kindFilter !== "all" && d.kind !== kindFilter) {
return false;
}
if (!kw) return true; if (!kw) return true;
const blob = [ const blob = [
d.kind, d.kind,
@ -341,7 +345,14 @@ export function BizComparePage() {
}); });
}, [activeRunSheet, kindFilter, debouncedResultKw]); }, [activeRunSheet, kindFilter, debouncedResultKw]);
const sheetSummary = activeRunSheet?.summary || {}; const kindLabel = (kind: string) => {
if (kind === "added") return t("bizCompare.kindAddedShort");
if (kind === "removed") return t("bizCompare.kindRemovedShort");
if (kind === "changed") return t("bizCompare.kindChangedShort");
if (kind === "unchanged") return t("bizCompare.kindUnchangedShort");
return kind;
};
const summary = runDetail?.summary || {}; const summary = runDetail?.summary || {};
const updateActiveSheet = (patch: Partial<MetricSheet>) => { const updateActiveSheet = (patch: Partial<MetricSheet>) => {
@ -573,7 +584,7 @@ export function BizComparePage() {
setJobDetailTab("config"); setJobDetailTab("config");
setRunDetail(null); setRunDetail(null);
setResultSheetId(""); setResultSheetId("");
setKindFilter("all"); setKindFilter("diff");
setResultKw(""); setResultKw("");
const job = jobs.find((x) => x.id === id); const job = jobs.find((x) => x.id === id);
if (job) resetJobForm(job); if (job) resetJobForm(job);
@ -649,7 +660,7 @@ export function BizComparePage() {
try { try {
const d = await bizCompareGetRun(runId); const d = await bizCompareGetRun(runId);
setRunDetail(d); setRunDetail(d);
setKindFilter("all"); setKindFilter("diff");
setResultKw(""); setResultKw("");
setJobDetailTab("result"); setJobDetailTab("result");
} catch (e) { } catch (e) {
@ -1175,59 +1186,86 @@ export function BizComparePage() {
{jobDetailTab === "config" ? ( {jobDetailTab === "config" ? (
renderJobForm(false) renderJobForm(false)
) : ( ) : (
<div className="bs-workbook-body flex flex-col gap-2" style={{ flex: 1, minHeight: 0 }}> <div className="bs-workbook-body bs-cmp-result flex flex-col gap-3" style={{ flex: 1, minHeight: 0 }}>
<FieldSelect <div className="bs-cmp-result__toolbar">
label={t("bizCompare.pickBatchRun")} <FieldSelect
value={runDetail?.id || ""} label={t("bizCompare.pickBatchRun")}
onChange={(e) => { value={runDetail?.id || ""}
const id = e.target.value; onChange={(e) => {
if (id) void loadRun(id); const id = e.target.value;
}} if (id) void loadRun(id);
fullWidth }}
> fullWidth
<option value="">{t("bizCompare.pickRun")}</option> >
{runs.map((r) => ( <option value="">{t("bizCompare.pickRun")}</option>
<option key={r.id} value={r.id}> {runs.map((r) => (
{fmtTime(r.created_at)} · before= <option key={r.id} value={r.id}>
{String(r.before_batch_id || "").slice(0, 8)}… / after= {fmtTime(r.created_at)} · +{r.summary?.added ?? 0} / −{r.summary?.removed ?? 0} / ~
{String(r.after_batch_id || "").slice(0, 8)}… · +{r.summary?.added ?? 0}/− {r.summary?.changed ?? 0}
{r.summary?.removed ?? 0}/~{r.summary?.changed ?? 0} </option>
</option> ))}
))} </FieldSelect>
</FieldSelect> </div>
{runDetail ? ( {runDetail ? (
<> <>
<div className="pt-list-kpis"> <div className="bs-cmp-kpis">
<div className="pt-list-kpi"> <button
<div className="pt-list-kpi__label">+</div> type="button"
<div className="pt-list-kpi__value">{summary.added ?? 0}</div> className={`bs-cmp-kpi bs-cmp-kpi--added${kindFilter === "added" ? " is-active" : ""}`}
</div> onClick={() => setKindFilter("added")}
<div className="pt-list-kpi"> >
<div className="pt-list-kpi__label">−</div> <span className="bs-cmp-kpi__label">{t("bizCompare.kindAddedShort")}</span>
<div className="pt-list-kpi__value">{summary.removed ?? 0}</div> <span className="bs-cmp-kpi__value">{summary.added ?? 0}</span>
</div> </button>
<div className="pt-list-kpi pt-list-kpi--live"> <button
<div className="pt-list-kpi__label">~</div> type="button"
<div className="pt-list-kpi__value">{summary.changed ?? 0}</div> className={`bs-cmp-kpi bs-cmp-kpi--removed${kindFilter === "removed" ? " is-active" : ""}`}
</div> onClick={() => setKindFilter("removed")}
<div className="pt-list-kpi"> >
<div className="pt-list-kpi__label">=</div> <span className="bs-cmp-kpi__label">{t("bizCompare.kindRemovedShort")}</span>
<div className="pt-list-kpi__value">{summary.unchanged ?? 0}</div> <span className="bs-cmp-kpi__value">{summary.removed ?? 0}</span>
</div> </button>
<button
type="button"
className={`bs-cmp-kpi bs-cmp-kpi--changed${kindFilter === "changed" ? " is-active" : ""}`}
onClick={() => setKindFilter("changed")}
>
<span className="bs-cmp-kpi__label">{t("bizCompare.kindChangedShort")}</span>
<span className="bs-cmp-kpi__value">{summary.changed ?? 0}</span>
</button>
<button
type="button"
className={`bs-cmp-kpi bs-cmp-kpi--unchanged${kindFilter === "unchanged" ? " is-active" : ""}`}
onClick={() => setKindFilter("unchanged")}
>
<span className="bs-cmp-kpi__label">{t("bizCompare.kindUnchangedShort")}</span>
<span className="bs-cmp-kpi__value">{summary.unchanged ?? 0}</span>
</button>
</div> </div>
<div className="filter-inline bs-sheet-filter"> <div className="bs-cmp-filter-bar">
<FieldSelect <div className="bs-cmp-kind-pills" role="tablist">
value={kindFilter} {(
onChange={(e) => setKindFilter(e.target.value as KindFilter)} [
> ["diff", t("bizCompare.kindDiff")],
<option value="all">{t("bizCompare.kindAll")}</option> ["all", t("bizCompare.kindAll")],
<option value="added">{t("bizCompare.kindAdded")}</option> ["added", t("bizCompare.kindAddedShort")],
<option value="removed">{t("bizCompare.kindRemoved")}</option> ["removed", t("bizCompare.kindRemovedShort")],
<option value="changed">{t("bizCompare.kindChanged")}</option> ["changed", t("bizCompare.kindChangedShort")],
<option value="unchanged">{t("bizCompare.kindUnchanged")}</option> ["unchanged", t("bizCompare.kindUnchangedShort")],
</FieldSelect> ] as const
).map(([id, label]) => (
<button
key={id}
type="button"
className={`bs-cmp-kind-pill${kindFilter === id ? " is-active" : ""}`}
onClick={() => setKindFilter(id)}
>
{label}
</button>
))}
</div>
<Input <Input
value={resultKw} value={resultKw}
placeholder={t("bizCompare.resultFilterPh")} placeholder={t("bizCompare.resultFilterPh")}
@ -1243,32 +1281,14 @@ export function BizComparePage() {
<table className="data-table pt-list-table bs-cmp-diff-table"> <table className="data-table pt-list-table bs-cmp-diff-table">
<thead> <thead>
<tr> <tr>
<th rowSpan={resultColumns.compare.length ? 2 : 1}> <th className="bs-cmp-col-kind">{t("bizCompare.colKind")}</th>
{t("bizCompare.colKind")}
</th>
{resultColumns.keys.map((k) => ( {resultColumns.keys.map((k) => (
<th key={k} rowSpan={resultColumns.compare.length ? 2 : 1}> <th key={k}>{k}</th>
{k}
</th>
))} ))}
{resultColumns.compare.map((f) => ( {resultColumns.compare.map((f) => (
<th key={f} colSpan={2} className="bs-cmp-pair-head"> <th key={f}>{f}</th>
{f}
</th>
))} ))}
</tr> </tr>
{resultColumns.compare.length ? (
<tr>
{resultColumns.compare.flatMap((f) => [
<th key={`${f}-pre`} className="bs-cmp-pre-head">
pre
</th>,
<th key={`${f}-post`} className="bs-cmp-post-head">
post
</th>,
])}
</tr>
) : null}
</thead> </thead>
<tbody> <tbody>
{filteredDiffs.slice(0, 2000).map((d, i) => { {filteredDiffs.slice(0, 2000).map((d, i) => {
@ -1276,55 +1296,52 @@ export function BizComparePage() {
const post = (d.after || {}) as Record<string, unknown>; const post = (d.after || {}) as Record<string, unknown>;
return ( return (
<tr key={i} className={`bs-cmp-row bs-cmp-row--${d.kind}`}> <tr key={i} className={`bs-cmp-row bs-cmp-row--${d.kind}`}>
<td> <td className="bs-cmp-col-kind">
<NmStatusChip <span className={`bs-cmp-badge bs-cmp-badge--${d.kind}`}>
color={ {kindLabel(d.kind)}
d.kind === "added" </span>
? "success"
: d.kind === "removed"
? "danger"
: d.kind === "changed"
? "warning"
: "default"
}
>
{d.kind}
</NmStatusChip>
</td> </td>
{resultColumns.keys.map((k) => ( {resultColumns.keys.map((k) => (
<td key={k}> <td key={k} className="bs-cmp-key-cell">
<code>{cellText(d.key?.[k] ?? pre[k] ?? post[k]) || "—"}</code> {cellText(d.key?.[k] ?? pre[k] ?? post[k]) || "—"}
</td> </td>
))} ))}
{resultColumns.compare.flatMap((f) => { {resultColumns.compare.map((f) => {
const changed =
Boolean(d.changes?.[f]) ||
(d.kind === "changed" && cellText(pre[f]) !== cellText(post[f]));
const pv = cellText(pre[f]); const pv = cellText(pre[f]);
const av = cellText(post[f]); const av = cellText(post[f]);
const mismatch = changed && pv !== av; if (d.kind === "added") {
return [ return (
<td <td key={f} className="bs-cmp-val-cell">
key={`${f}-pre-${i}`} <span className="bs-cmp-val bs-cmp-val--post">{av || "—"}</span>
className={ </td>
d.kind === "removed" || mismatch );
? "bs-cmp-cell bs-cmp-cell--pre" }
: "bs-cmp-cell" if (d.kind === "removed") {
} return (
> <td key={f} className="bs-cmp-val-cell">
{d.kind === "added" ? "—" : pv || "—"} <span className="bs-cmp-val bs-cmp-val--pre">{pv || "—"}</span>
</td>, </td>
<td );
key={`${f}-post-${i}`} }
className={ const mismatch =
d.kind === "added" || mismatch Boolean(d.changes?.[f]) ||
? "bs-cmp-cell bs-cmp-cell--post" (d.kind === "changed" && pv !== av);
: "bs-cmp-cell" if (!mismatch) {
} return (
> <td key={f} className="bs-cmp-val-cell">
{d.kind === "removed" ? "—" : av || "—"} <span className="bs-cmp-val">{pv || av || "—"}</span>
</td>, </td>
]; );
}
return (
<td key={f} className="bs-cmp-val-cell bs-cmp-val-cell--diff">
<span className="bs-cmp-val bs-cmp-val--pre">{pv || "—"}</span>
<span className="bs-cmp-val-arrow" aria-hidden>
→
</span>
<span className="bs-cmp-val bs-cmp-val--post">{av || "—"}</span>
</td>
);
})} })}
</tr> </tr>
); );
@ -1333,7 +1350,7 @@ export function BizComparePage() {
<tr> <tr>
<td <td
colSpan={ colSpan={
1 + resultColumns.keys.length + resultColumns.compare.length * 2 1 + resultColumns.keys.length + Math.max(resultColumns.compare.length, 0)
} }
> >
<div className="pt-list-empty">{t("bizCompare.resultEmpty")}</div> <div className="pt-list-empty">{t("bizCompare.resultEmpty")}</div>
@ -1347,6 +1364,8 @@ export function BizComparePage() {
<div className="bs-sheet-tabs" role="tablist"> <div className="bs-sheet-tabs" role="tablist">
{runSheets.map((s) => { {runSheets.map((s) => {
const sc = s.summary || {}; const sc = s.summary || {};
const dirty =
Number(sc.added || 0) + Number(sc.removed || 0) + Number(sc.changed || 0);
return ( return (
<button <button
key={s.metric_id} key={s.metric_id}
@ -1356,14 +1375,15 @@ export function BizComparePage() {
> >
{s.metric_id} {s.metric_id}
<span className="bs-sheet-tab__count"> <span className="bs-sheet-tab__count">
+{sc.added ?? 0}/−{sc.removed ?? 0}/~{sc.changed ?? 0} {dirty > 0
? `${t("bizCompare.diffCount", { n: String(dirty) })}`
: t("bizCompare.kindUnchangedShort")}
{s.mode === "presence" ? ` · ${t("bizCompare.presenceShort")}` : ""} {s.mode === "presence" ? ` · ${t("bizCompare.presenceShort")}` : ""}
</span> </span>
</button> </button>
); );
})} })}
</div> </div>
{sheetSummary && Object.keys(sheetSummary).length ? null : null}
</> </>
) : ( ) : (
<div className="pt-list-empty">{t("bizCompare.noRuns")}</div> <div className="pt-list-empty">{t("bizCompare.noRuns")}</div>

View file

@ -74,6 +74,23 @@ type NeSourceFilter = "all" | "managed" | "ume";
const NE_PAGE_SIZE = 10; const NE_PAGE_SIZE = 10;
function fmtIntervalLabel(sec: number, daysLabel: string, hoursLabel: string) {
const hours = Math.max(1, Math.round(Number(sec || 0) / 3600) || 1);
if (hours % 24 === 0) return `${hours / 24} ${daysLabel}`;
return `${hours} ${hoursLabel}`;
}
function secToIntervalUi(sec: number): { value: number; unit: "days" | "hours" } {
const hours = Math.max(1, Math.round(Number(sec || 3600) / 3600) || 1);
if (hours % 24 === 0) return { value: hours / 24, unit: "days" };
return { value: hours, unit: "hours" };
}
function intervalUiToSec(value: number, unit: "days" | "hours") {
const n = Math.max(1, Number(value) || 1);
return unit === "days" ? n * 86400 : n * 3600;
}
function fmtTime(v?: string | null) { function fmtTime(v?: string | null) {
if (!v) return "—"; if (!v) return "—";
return formatSystemTime(v) || v; return formatSystemTime(v) || v;
@ -184,7 +201,8 @@ export function BizStatePage() {
const [profiles, setProfiles] = useState<Profile[]>([]); const [profiles, setProfiles] = useState<Profile[]>([]);
const [batches, setBatches] = useState<BatchRow[]>([]); const [batches, setBatches] = useState<BatchRow[]>([]);
const [taskTab, setTaskTab] = useState<TaskTab>("profiles"); const [taskTab, setTaskTab] = useState<TaskTab>("profiles");
const [intervalSec, setIntervalSec] = useState(300); const [intervalValue, setIntervalValue] = useState(1);
const [intervalUnit, setIntervalUnit] = useState<"days" | "hours">("hours");
const [retentionBatches, setRetentionBatches] = useState(30); const [retentionBatches, setRetentionBatches] = useState(30);
// VRF bind (inside task modal) // VRF bind (inside task modal)
@ -295,7 +313,9 @@ export function BizStatePage() {
const loadTask = async (id: string) => { const loadTask = async (id: string) => {
const task = await bizStateGetTask(id); const task = await bizStateGetTask(id);
setDetail(task); setDetail(task);
setIntervalSec(Math.max(60, Number(task.interval_sec || 300))); const ui = secToIntervalUi(Number(task.interval_sec || 3600));
setIntervalValue(ui.value);
setIntervalUnit(ui.unit);
setRetentionBatches(Math.max(1, Number(task.retention_batches || 30))); setRetentionBatches(Math.max(1, Number(task.retention_batches || 30)));
const b = await bizStateListBatches(id); const b = await bizStateListBatches(id);
setBatches((b.items || []) as BatchRow[]); setBatches((b.items || []) as BatchRow[]);
@ -371,7 +391,7 @@ export function BizStatePage() {
setBusy(true); setBusy(true);
try { try {
await bizStatePatchTask(taskId, { await bizStatePatchTask(taskId, {
interval_sec: Math.max(60, Number(intervalSec) || 300), interval_sec: intervalUiToSec(intervalValue, intervalUnit),
retention_batches: Math.max(1, Number(retentionBatches) || 30), retention_batches: Math.max(1, Number(retentionBatches) || 30),
}); });
showOk(t("bizState.scheduleSaved")); showOk(t("bizState.scheduleSaved"));
@ -587,7 +607,13 @@ export function BizStatePage() {
{row.source || "managed"} {row.source || "managed"}
</NmStatusChip> </NmStatusChip>
</td> </td>
<td className="pt-list-num">{row.interval_sec ?? 300}s</td> <td className="pt-list-num">
{fmtIntervalLabel(
Number(row.interval_sec || 3600),
t("bizState.intervalUnitDays"),
t("bizState.intervalUnitHours"),
)}
</td>
<td> <td>
<div className="pt-list-actions" style={{ flexWrap: "wrap", gap: 4 }}> <div className="pt-list-actions" style={{ flexWrap: "wrap", gap: 4 }}>
<NmStatusChip color={jobChipColor(row.status)}>{row.status}</NmStatusChip> <NmStatusChip color={jobChipColor(row.status)}>{row.status}</NmStatusChip>
@ -797,11 +823,30 @@ export function BizStatePage() {
<span>{t("bizState.interval")}</span> <span>{t("bizState.interval")}</span>
<Input <Input
type="number" type="number"
min={60} min={1}
max={86400} max={intervalUnit === "days" ? 365 : 8760}
value={String(intervalSec)} value={String(intervalValue)}
onChange={(e) => setIntervalSec(Math.max(60, Number(e.target.value) || 60))} onChange={(e) => {
const max = intervalUnit === "days" ? 365 : 8760;
setIntervalValue(Math.max(1, Math.min(max, Number(e.target.value) || 1)));
}}
/> />
<select
value={intervalUnit}
onChange={(e) => {
const next = e.target.value === "hours" ? "hours" : "days";
if (next === intervalUnit) return;
if (next === "hours") {
setIntervalValue(Math.max(1, Math.min(8760, intervalValue * 24)));
} else {
setIntervalValue(Math.max(1, Math.min(365, Math.round(intervalValue / 24) || 1)));
}
setIntervalUnit(next);
}}
>
<option value="days">{t("bizState.intervalUnitDays")}</option>
<option value="hours">{t("bizState.intervalUnitHours")}</option>
</select>
</label> </label>
<label className="config-sync-policy-field"> <label className="config-sync-policy-field">
<span>{t("bizState.retention")}</span> <span>{t("bizState.retention")}</span>