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,
note=str(body.get("note") or "")[:256],
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)),
created_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:
task.note = str(body.get("note") or "")[:256]
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:
task.retention_batches = max(1, int(body.get("retention_batches") or 30))
if "items" in body:

View file

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

View file

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

View file

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

View file

@ -10020,32 +10020,210 @@ html.login-page--paused .login-page__flare {
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 {
flex: 1 1 auto;
min-height: 260px;
max-height: min(46vh, 520px);
min-height: 280px;
max-height: min(48vh, 560px);
}
.bs-cmp-diff-table .bs-cmp-pair-head {
text-align: center;
.bs-cmp-diff-table {
border-collapse: separate;
border-spacing: 0;
}
.bs-cmp-diff-table .bs-cmp-pre-head,
.bs-cmp-diff-table .bs-cmp-post-head {
.bs-cmp-diff-table thead th {
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;
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 {
background: rgba(34, 197, 94, 0.08);
background: rgba(34, 197, 94, 0.05);
}
.bs-cmp-row--removed {
background: rgba(239, 68, 68, 0.08);
background: rgba(239, 68, 68, 0.05);
}
.bs-cmp-row--changed {
background: rgba(245, 158, 11, 0.06);
background: transparent;
}
.bs-cmp-row--unchanged {
opacity: 0.78;
}
.bs-cmp-cell--pre {
@ -10058,6 +10236,12 @@ html.login-page--paused .login-page__flare {
font-weight: 600;
}
@media (max-width: 720px) {
.bs-cmp-kpis {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
.nm-config-modal__body {
flex: 1 1 auto;
width: 100%;

View file

@ -30,7 +30,7 @@ import { jobChipColor, NmStatusChip } from "./nmChips";
type PageTab = "templates" | "jobs";
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 BatchOpt = { id: string; status: string; row_count: number; started_at?: string | null };
@ -196,7 +196,7 @@ export function BizComparePage() {
const [resultSheetId, setResultSheetId] = useState("");
// result filters
const [kindFilter, setKindFilter] = useState<KindFilter>("all");
const [kindFilter, setKindFilter] = useState<KindFilter>("diff");
const [resultKw, setResultKw] = useState("");
const debouncedResultKw = useDebouncedValue(resultKw, 200);
@ -325,7 +325,11 @@ export function BizComparePage() {
const diffs = (activeRunSheet?.diffs || []) as DiffRow[];
const kw = debouncedResultKw.trim().toLowerCase();
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;
const blob = [
d.kind,
@ -341,7 +345,14 @@ export function BizComparePage() {
});
}, [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 updateActiveSheet = (patch: Partial<MetricSheet>) => {
@ -573,7 +584,7 @@ export function BizComparePage() {
setJobDetailTab("config");
setRunDetail(null);
setResultSheetId("");
setKindFilter("all");
setKindFilter("diff");
setResultKw("");
const job = jobs.find((x) => x.id === id);
if (job) resetJobForm(job);
@ -649,7 +660,7 @@ export function BizComparePage() {
try {
const d = await bizCompareGetRun(runId);
setRunDetail(d);
setKindFilter("all");
setKindFilter("diff");
setResultKw("");
setJobDetailTab("result");
} catch (e) {
@ -1175,59 +1186,86 @@ export function BizComparePage() {
{jobDetailTab === "config" ? (
renderJobForm(false)
) : (
<div className="bs-workbook-body flex flex-col gap-2" style={{ flex: 1, minHeight: 0 }}>
<FieldSelect
label={t("bizCompare.pickBatchRun")}
value={runDetail?.id || ""}
onChange={(e) => {
const id = e.target.value;
if (id) void loadRun(id);
}}
fullWidth
>
<option value="">{t("bizCompare.pickRun")}</option>
{runs.map((r) => (
<option key={r.id} value={r.id}>
{fmtTime(r.created_at)} · before=
{String(r.before_batch_id || "").slice(0, 8)}… / after=
{String(r.after_batch_id || "").slice(0, 8)}… · +{r.summary?.added ?? 0}/−
{r.summary?.removed ?? 0}/~{r.summary?.changed ?? 0}
</option>
))}
</FieldSelect>
<div className="bs-workbook-body bs-cmp-result flex flex-col gap-3" style={{ flex: 1, minHeight: 0 }}>
<div className="bs-cmp-result__toolbar">
<FieldSelect
label={t("bizCompare.pickBatchRun")}
value={runDetail?.id || ""}
onChange={(e) => {
const id = e.target.value;
if (id) void loadRun(id);
}}
fullWidth
>
<option value="">{t("bizCompare.pickRun")}</option>
{runs.map((r) => (
<option key={r.id} value={r.id}>
{fmtTime(r.created_at)} · +{r.summary?.added ?? 0} / −{r.summary?.removed ?? 0} / ~
{r.summary?.changed ?? 0}
</option>
))}
</FieldSelect>
</div>
{runDetail ? (
<>
<div className="pt-list-kpis">
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">+</div>
<div className="pt-list-kpi__value">{summary.added ?? 0}</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">−</div>
<div className="pt-list-kpi__value">{summary.removed ?? 0}</div>
</div>
<div className="pt-list-kpi pt-list-kpi--live">
<div className="pt-list-kpi__label">~</div>
<div className="pt-list-kpi__value">{summary.changed ?? 0}</div>
</div>
<div className="pt-list-kpi">
<div className="pt-list-kpi__label">=</div>
<div className="pt-list-kpi__value">{summary.unchanged ?? 0}</div>
</div>
<div className="bs-cmp-kpis">
<button
type="button"
className={`bs-cmp-kpi bs-cmp-kpi--added${kindFilter === "added" ? " is-active" : ""}`}
onClick={() => setKindFilter("added")}
>
<span className="bs-cmp-kpi__label">{t("bizCompare.kindAddedShort")}</span>
<span className="bs-cmp-kpi__value">{summary.added ?? 0}</span>
</button>
<button
type="button"
className={`bs-cmp-kpi bs-cmp-kpi--removed${kindFilter === "removed" ? " is-active" : ""}`}
onClick={() => setKindFilter("removed")}
>
<span className="bs-cmp-kpi__label">{t("bizCompare.kindRemovedShort")}</span>
<span className="bs-cmp-kpi__value">{summary.removed ?? 0}</span>
</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 className="filter-inline bs-sheet-filter">
<FieldSelect
value={kindFilter}
onChange={(e) => setKindFilter(e.target.value as KindFilter)}
>
<option value="all">{t("bizCompare.kindAll")}</option>
<option value="added">{t("bizCompare.kindAdded")}</option>
<option value="removed">{t("bizCompare.kindRemoved")}</option>
<option value="changed">{t("bizCompare.kindChanged")}</option>
<option value="unchanged">{t("bizCompare.kindUnchanged")}</option>
</FieldSelect>
<div className="bs-cmp-filter-bar">
<div className="bs-cmp-kind-pills" role="tablist">
{(
[
["diff", t("bizCompare.kindDiff")],
["all", t("bizCompare.kindAll")],
["added", t("bizCompare.kindAddedShort")],
["removed", t("bizCompare.kindRemovedShort")],
["changed", t("bizCompare.kindChangedShort")],
["unchanged", t("bizCompare.kindUnchangedShort")],
] 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
value={resultKw}
placeholder={t("bizCompare.resultFilterPh")}
@ -1243,32 +1281,14 @@ export function BizComparePage() {
<table className="data-table pt-list-table bs-cmp-diff-table">
<thead>
<tr>
<th rowSpan={resultColumns.compare.length ? 2 : 1}>
{t("bizCompare.colKind")}
</th>
<th className="bs-cmp-col-kind">{t("bizCompare.colKind")}</th>
{resultColumns.keys.map((k) => (
<th key={k} rowSpan={resultColumns.compare.length ? 2 : 1}>
{k}
</th>
<th key={k}>{k}</th>
))}
{resultColumns.compare.map((f) => (
<th key={f} colSpan={2} className="bs-cmp-pair-head">
{f}
</th>
<th key={f}>{f}</th>
))}
</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>
<tbody>
{filteredDiffs.slice(0, 2000).map((d, i) => {
@ -1276,55 +1296,52 @@ export function BizComparePage() {
const post = (d.after || {}) as Record<string, unknown>;
return (
<tr key={i} className={`bs-cmp-row bs-cmp-row--${d.kind}`}>
<td>
<NmStatusChip
color={
d.kind === "added"
? "success"
: d.kind === "removed"
? "danger"
: d.kind === "changed"
? "warning"
: "default"
}
>
{d.kind}
</NmStatusChip>
<td className="bs-cmp-col-kind">
<span className={`bs-cmp-badge bs-cmp-badge--${d.kind}`}>
{kindLabel(d.kind)}
</span>
</td>
{resultColumns.keys.map((k) => (
<td key={k}>
<code>{cellText(d.key?.[k] ?? pre[k] ?? post[k]) || "—"}</code>
<td key={k} className="bs-cmp-key-cell">
{cellText(d.key?.[k] ?? pre[k] ?? post[k]) || "—"}
</td>
))}
{resultColumns.compare.flatMap((f) => {
const changed =
Boolean(d.changes?.[f]) ||
(d.kind === "changed" && cellText(pre[f]) !== cellText(post[f]));
{resultColumns.compare.map((f) => {
const pv = cellText(pre[f]);
const av = cellText(post[f]);
const mismatch = changed && pv !== av;
return [
<td
key={`${f}-pre-${i}`}
className={
d.kind === "removed" || mismatch
? "bs-cmp-cell bs-cmp-cell--pre"
: "bs-cmp-cell"
}
>
{d.kind === "added" ? "—" : pv || "—"}
</td>,
<td
key={`${f}-post-${i}`}
className={
d.kind === "added" || mismatch
? "bs-cmp-cell bs-cmp-cell--post"
: "bs-cmp-cell"
}
>
{d.kind === "removed" ? "—" : av || "—"}
</td>,
];
if (d.kind === "added") {
return (
<td key={f} className="bs-cmp-val-cell">
<span className="bs-cmp-val bs-cmp-val--post">{av || "—"}</span>
</td>
);
}
if (d.kind === "removed") {
return (
<td key={f} className="bs-cmp-val-cell">
<span className="bs-cmp-val bs-cmp-val--pre">{pv || "—"}</span>
</td>
);
}
const mismatch =
Boolean(d.changes?.[f]) ||
(d.kind === "changed" && pv !== av);
if (!mismatch) {
return (
<td key={f} className="bs-cmp-val-cell">
<span className="bs-cmp-val">{pv || av || "—"}</span>
</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>
);
@ -1333,7 +1350,7 @@ export function BizComparePage() {
<tr>
<td
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>
@ -1347,6 +1364,8 @@ export function BizComparePage() {
<div className="bs-sheet-tabs" role="tablist">
{runSheets.map((s) => {
const sc = s.summary || {};
const dirty =
Number(sc.added || 0) + Number(sc.removed || 0) + Number(sc.changed || 0);
return (
<button
key={s.metric_id}
@ -1356,14 +1375,15 @@ export function BizComparePage() {
>
{s.metric_id}
<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")}` : ""}
</span>
</button>
);
})}
</div>
{sheetSummary && Object.keys(sheetSummary).length ? null : null}
</>
) : (
<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;
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) {
if (!v) return "—";
return formatSystemTime(v) || v;
@ -184,7 +201,8 @@ export function BizStatePage() {
const [profiles, setProfiles] = useState<Profile[]>([]);
const [batches, setBatches] = useState<BatchRow[]>([]);
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);
// VRF bind (inside task modal)
@ -295,7 +313,9 @@ export function BizStatePage() {
const loadTask = async (id: string) => {
const task = await bizStateGetTask(id);
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)));
const b = await bizStateListBatches(id);
setBatches((b.items || []) as BatchRow[]);
@ -371,7 +391,7 @@ export function BizStatePage() {
setBusy(true);
try {
await bizStatePatchTask(taskId, {
interval_sec: Math.max(60, Number(intervalSec) || 300),
interval_sec: intervalUiToSec(intervalValue, intervalUnit),
retention_batches: Math.max(1, Number(retentionBatches) || 30),
});
showOk(t("bizState.scheduleSaved"));
@ -587,7 +607,13 @@ export function BizStatePage() {
{row.source || "managed"}
</NmStatusChip>
</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>
<div className="pt-list-actions" style={{ flexWrap: "wrap", gap: 4 }}>
<NmStatusChip color={jobChipColor(row.status)}>{row.status}</NmStatusChip>
@ -797,11 +823,30 @@ export function BizStatePage() {
<span>{t("bizState.interval")}</span>
<Input
type="number"
min={60}
max={86400}
value={String(intervalSec)}
onChange={(e) => setIntervalSec(Math.max(60, Number(e.target.value) || 60))}
min={1}
max={intervalUnit === "days" ? 365 : 8760}
value={String(intervalValue)}
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 className="config-sync-policy-field">
<span>{t("bizState.retention")}</span>