diff --git a/netx_api/biz_state/service.py b/netx_api/biz_state/service.py index b87d737..5b3e98b 100644 --- a/netx_api/biz_state/service.py +++ b/netx_api/biz_state/service.py @@ -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: diff --git a/netx_api/biz_state_router.py b/netx_api/biz_state_router.py index 7d3a5b3..0e41145 100644 --- a/netx_api/biz_state_router.py +++ b/netx_api/biz_state_router.py @@ -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) diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index 2d133c4..838d007 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -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", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index d140b7b..444329e 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -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: "比对完成", diff --git a/web/src/index.css b/web/src/index.css index 518f35f..07aa533 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -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%; diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index 4691d6b..5e6a3d3 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -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("all"); + const [kindFilter, setKindFilter] = useState("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) => { @@ -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) ) : ( -
- { - const id = e.target.value; - if (id) void loadRun(id); - }} - fullWidth - > - - {runs.map((r) => ( - - ))} - +
+
+ { + const id = e.target.value; + if (id) void loadRun(id); + }} + fullWidth + > + + {runs.map((r) => ( + + ))} + +
{runDetail ? ( <> -
-
-
+
-
{summary.added ?? 0}
-
-
-
−
-
{summary.removed ?? 0}
-
-
-
~
-
{summary.changed ?? 0}
-
-
-
=
-
{summary.unchanged ?? 0}
-
+
+ + + +
-
- 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]) => ( + + ))} +
- - {t("bizCompare.colKind")} - + {t("bizCompare.colKind")} {resultColumns.keys.map((k) => ( - - {k} - + {k} ))} {resultColumns.compare.map((f) => ( - - {f} - + {f} ))} - {resultColumns.compare.length ? ( - - {resultColumns.compare.flatMap((f) => [ - - pre - , - - post - , - ])} - - ) : null} {filteredDiffs.slice(0, 2000).map((d, i) => { @@ -1276,55 +1296,52 @@ export function BizComparePage() { const post = (d.after || {}) as Record; return ( - - - {d.kind} - + + + {kindLabel(d.kind)} + {resultColumns.keys.map((k) => ( - - {cellText(d.key?.[k] ?? pre[k] ?? post[k]) || "—"} + + {cellText(d.key?.[k] ?? pre[k] ?? post[k]) || "—"} ))} - {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 [ - - {d.kind === "added" ? "—" : pv || "—"} - , - - {d.kind === "removed" ? "—" : av || "—"} - , - ]; + if (d.kind === "added") { + return ( + + {av || "—"} + + ); + } + if (d.kind === "removed") { + return ( + + {pv || "—"} + + ); + } + const mismatch = + Boolean(d.changes?.[f]) || + (d.kind === "changed" && pv !== av); + if (!mismatch) { + return ( + + {pv || av || "—"} + + ); + } + return ( + + {pv || "—"} + + → + + {av || "—"} + + ); })} ); @@ -1333,7 +1350,7 @@ export function BizComparePage() {
{t("bizCompare.resultEmpty")}
@@ -1347,6 +1364,8 @@ export function BizComparePage() {
{runSheets.map((s) => { const sc = s.summary || {}; + const dirty = + Number(sc.added || 0) + Number(sc.removed || 0) + Number(sc.changed || 0); return ( ); })}
- {sheetSummary && Object.keys(sheetSummary).length ? null : null} ) : (
{t("bizCompare.noRuns")}
diff --git a/web/src/pages/network/BizStatePage.tsx b/web/src/pages/network/BizStatePage.tsx index fce7c7d..cfcce4d 100644 --- a/web/src/pages/network/BizStatePage.tsx +++ b/web/src/pages/network/BizStatePage.tsx @@ -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([]); const [batches, setBatches] = useState([]); const [taskTab, setTaskTab] = useState("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"} - {row.interval_sec ?? 300}s + + {fmtIntervalLabel( + Number(row.interval_sec || 3600), + t("bizState.intervalUnitDays"), + t("bizState.intervalUnitHours"), + )} +
{row.status} @@ -797,11 +823,30 @@ export function BizStatePage() { {t("bizState.interval")} 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))); + }} /> +