mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 05:30:46 +08:00
Align biz-state interval to days/hours and polish compare result workbook.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
29b08f9807
commit
ff37d0866a
7 changed files with 411 additions and 146 deletions
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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: "比对完成",
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue