mirror of
https://github.com/hansjone/netx.git
synced 2026-10-09 03:10: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
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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: "比对完成",
|
||||||
|
|
|
||||||
|
|
@ -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%;
|
||||||
|
|
|
||||||
|
|
@ -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,7 +1186,8 @@ 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 }}>
|
||||||
|
<div className="bs-cmp-result__toolbar">
|
||||||
<FieldSelect
|
<FieldSelect
|
||||||
label={t("bizCompare.pickBatchRun")}
|
label={t("bizCompare.pickBatchRun")}
|
||||||
value={runDetail?.id || ""}
|
value={runDetail?.id || ""}
|
||||||
|
|
@ -1188,46 +1200,72 @@ export function BizComparePage() {
|
||||||
<option value="">{t("bizCompare.pickRun")}</option>
|
<option value="">{t("bizCompare.pickRun")}</option>
|
||||||
{runs.map((r) => (
|
{runs.map((r) => (
|
||||||
<option key={r.id} value={r.id}>
|
<option key={r.id} value={r.id}>
|
||||||
{fmtTime(r.created_at)} · before=
|
{fmtTime(r.created_at)} · +{r.summary?.added ?? 0} / −{r.summary?.removed ?? 0} / ~
|
||||||
{String(r.before_batch_id || "").slice(0, 8)}… / after=
|
{r.summary?.changed ?? 0}
|
||||||
{String(r.after_batch_id || "").slice(0, 8)}… · +{r.summary?.added ?? 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")],
|
||||||
|
["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)}
|
||||||
>
|
>
|
||||||
<option value="all">{t("bizCompare.kindAll")}</option>
|
{label}
|
||||||
<option value="added">{t("bizCompare.kindAdded")}</option>
|
</button>
|
||||||
<option value="removed">{t("bizCompare.kindRemoved")}</option>
|
))}
|
||||||
<option value="changed">{t("bizCompare.kindChanged")}</option>
|
</div>
|
||||||
<option value="unchanged">{t("bizCompare.kindUnchanged")}</option>
|
|
||||||
</FieldSelect>
|
|
||||||
<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") {
|
||||||
{d.kind === "added" ? "—" : pv || "—"}
|
return (
|
||||||
</td>,
|
<td key={f} className="bs-cmp-val-cell">
|
||||||
<td
|
<span className="bs-cmp-val bs-cmp-val--pre">{pv || "—"}</span>
|
||||||
key={`${f}-post-${i}`}
|
</td>
|
||||||
className={
|
);
|
||||||
d.kind === "added" || mismatch
|
|
||||||
? "bs-cmp-cell bs-cmp-cell--post"
|
|
||||||
: "bs-cmp-cell"
|
|
||||||
}
|
}
|
||||||
>
|
const mismatch =
|
||||||
{d.kind === "removed" ? "—" : av || "—"}
|
Boolean(d.changes?.[f]) ||
|
||||||
</td>,
|
(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>
|
</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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue