diff --git a/packaging/README.md b/packaging/README.md index ec06370..ec2353b 100644 --- a/packaging/README.md +++ b/packaging/README.md @@ -56,15 +56,17 @@ Optional local zip only: `build_release.ps1 -CreateZip` (not for release upload) 1. Run `NetX-Setup-x.y.z.exe` (admin). 2. **First install:** Database page — choose built-in (offline) or external PostgreSQL (host/port/user/password/db; connection must succeed to continue). -3. **Already installed (upgrade):** if `%ProgramData%\NetX\.env` already has `NETX_DB_MODE`, Setup **skips** the Database page, stops running NetX, replaces program files, and **keeps** data / DB settings. Use Start Menu → **Reconfigure database** only when you need to change DB mode. +3. **Already installed:** if `%ProgramData%\NetX\.env` already has `NETX_DB_MODE`, Setup shows an **Install mode** page: + - **Update** (default) — skip Database page; stop NetX; replace program files; **keep** ProgramData / DB settings. + - **Reinstall** — same Database wizard as first install (`ApplyDatabaseConfig`); does **not** delete ProgramData (use Uninstall → delete data for a wipe). 4. Files → `%ProgramFiles%\NetX\` (program root). 5. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets). 6. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI** / **Reconfigure database**. Silent first install (bundled default): `/SILENT /DbMode=bundled` Silent external: `/SILENT /DbMode=external /DbHost=... /DbPort=5432 /DbUser=... /DbPassword=... /DbName=...` -Silent upgrade over existing data: `/VERYSILENT /NORESTART` (auto-detects `.env`; or force `/SkipDbPage=1`) -Force DB wizard on an existing install: `/ForceDbPage=1` +Silent update over existing data: `/VERYSILENT /NORESTART /InstallMode=update` (also `/SkipDbPage=1`) +Silent reinstall (DB wizard via params): `/VERYSILENT /InstallMode=reinstall /DbMode=bundled` (or external `/DbHost`…) — also `/ForceDbPage=1` Optional credential key (reuse encrypted NE passwords from another install): `/CredentialSecretKey=...` — omit to auto-generate. ## Database modes @@ -79,7 +81,7 @@ Existing Windows deploys that only set `NETX_DATABASE_URL` keep working: never s ## Manual update -Preferred: run a newer `NetX-Setup-*.exe` over the same machine (upgrade mode keeps ProgramData). +Preferred: run a newer `NetX-Setup-*.exe` and choose **Update** (or silent `/InstallMode=update`) so ProgramData is kept. Setup (elevated) always relinks `.venv` → `python/runtime` after file copy so tray start works without write access under Program Files. Legacy/dev zip (only if you built with `-CreateZip`): diff --git a/packaging/_common.ps1 b/packaging/_common.ps1 index 6c2c9dd..0a22f55 100644 --- a/packaging/_common.ps1 +++ b/packaging/_common.ps1 @@ -404,8 +404,23 @@ function Repair-NetxShippedVenv { "version = $ver", "executable = $rtPyAbs" ) - Set-Content -LiteralPath $cfgPath -Value ($lines -join "`r`n") -Encoding ascii - return $true + $desired = ($lines -join "`r`n") + # Skip write when already correct — Users cannot modify Program Files after Setup. + try { + $cur = ([IO.File]::ReadAllText($cfgPath) -replace "`r`n", "`n" -replace "`r", "`n").Trim() + $want = ($desired -replace "`r`n", "`n" -replace "`r", "`n").Trim() + if ($cur -eq $want) { + return $true + } + } catch {} + + try { + Set-Content -LiteralPath $cfgPath -Value $desired -Encoding ascii -ErrorAction Stop + return $true + } catch { + # Non-elevated tray/start: leave cfg as-is; caller may still run if paths happen to work. + return $false + } } function Test-NetxVenvRunnable { @@ -446,7 +461,11 @@ function Ensure-NetxVenv { $rtPy = Join-Path (Get-NetxBundledPythonRoot -ProgramRoot $ProgramRoot) "python.exe" if (Test-Path -LiteralPath $rtPy) { - $null = Repair-NetxShippedVenv -ProgramRoot $ProgramRoot + try { + $null = Repair-NetxShippedVenv -ProgramRoot $ProgramRoot + } catch { + # Access denied under Program Files when not elevated — ignore if venv already runs. + } } if ((Test-Path -LiteralPath $venvPy) -and -not $ForcePip) { @@ -455,11 +474,19 @@ function Ensure-NetxVenv { } Write-Host "[WARN] Shipped .venv exists but Python cannot start (broken pyvenv.cfg or missing runtime)." -ForegroundColor Yellow if (Test-Path -LiteralPath $rtPy) { - $null = Repair-NetxShippedVenv -ProgramRoot $ProgramRoot - if (Test-NetxVenvRunnable -VenvPython $venvPy) { + $repaired = $false + try { + $repaired = [bool](Repair-NetxShippedVenv -ProgramRoot $ProgramRoot) + } catch { + $repaired = $false + } + if ($repaired -and (Test-NetxVenvRunnable -VenvPython $venvPy)) { Write-Host "==> Repaired .venv to use bundled python/runtime" -ForegroundColor Green return $venvPy } + if (-not $repaired) { + throw "venv_needs_admin_repair: pyvenv.cfg still points at the build PC. Re-run Setup (Update) as Administrator, or Start Menu → Reconfigure database once elevated." + } } if (-not $ForcePip) { throw "venv_not_runnable: reinstall NetX Setup (ships python/runtime + .venv) or run repair as admin" diff --git a/packaging/check_update.ps1 b/packaging/check_update.ps1 index 7e3fd46..0d45bf9 100644 --- a/packaging/check_update.ps1 +++ b/packaging/check_update.ps1 @@ -404,15 +404,22 @@ try { $env:NETX_UPDATE_ELEVATED = "1" if ($isSetup) { - # Silent Setup over an existing configured install skips the DB wizard - # (see installer/netx.iss GSkipDbPage) and preserves ProgramData. + # Silent Setup update mode: skip DB wizard, keep ProgramData + # (see installer/netx.iss /InstallMode=update). Write-Host "==> Applying update via silent Setup.exe" - $setupArgs = "/VERYSILENT /NORESTART /SUPPRESSMSGBOXES /DIR=`"$prog`" /SkipDbPage=1" + $setupArgs = "/VERYSILENT /NORESTART /SUPPRESSMSGBOXES /DIR=`"$prog`" /InstallMode=update" $sp = Start-Process -FilePath $pkgPath -ArgumentList $setupArgs -Wait -PassThru if ($null -eq $sp.ExitCode -or $sp.ExitCode -ne 0) { $code = if ($null -eq $sp.ExitCode) { "null" } else { $sp.ExitCode } throw "setup_update_failed: exit $code" } + # Setup post-install also repairs; belt-and-suspenders when running elevated apply. + $repairPs1 = Join-Path $prog "packaging\repair_venv.ps1" + if (Test-Path -LiteralPath $repairPs1) { + Write-Host "==> Relinking .venv to bundled python/runtime" + & powershell -NoProfile -ExecutionPolicy Bypass -File $repairPs1 ` + -ProgramRoot $prog -DataRoot $data + } Write-Host "==> Restarting NetX after Setup" & (Join-Path $PSScriptRoot "start_netx_app.ps1") ` -ProgramRoot $prog -DataRoot $data -SkipBrowser diff --git a/packaging/installer/netx.iss b/packaging/installer/netx.iss index 7e4a20e..42b836e 100644 --- a/packaging/installer/netx.iss +++ b/packaging/installer/netx.iss @@ -5,7 +5,7 @@ #define MyAppName "NetX" #ifndef MyAppVersion - #define MyAppVersion "0.4.11" + #define MyAppVersion "0.4.12" #endif #define MyAppPublisher "NetX" #define MyAppURL "https://github.com/hansjone/netx" @@ -78,6 +78,12 @@ english.DbBundledMissing=Built-in PostgreSQL files are missing from the install english.DbEnvMissing=Database configuration did not write %ProgramData%\NetX\.env. english.ReconfigureDb=Reconfigure database english.UpgradeKeepDb=Existing NetX data found - database settings will be kept (no wizard). +english.ModePageCaption=Install mode +english.ModePageDescription=An existing NetX installation was found. Choose update or reinstall. +english.ModeUpdate=Update existing installation (keep database settings) +english.ModeReinstall=Reinstall and reconfigure database (same as first install) +english.ModeUpdateHint=Replaces program files only. ProgramData\.env and database data are kept. +english.ModeReinstallHint=Shows the Database page again. Does not delete ProgramData unless you uninstall and choose to. chinesesimplified.CreateDesktopIcon=创建桌面快捷方式 chinesesimplified.SetupOptions=安装完成后: chinesesimplified.StartTrayNow=立即启动 NetX 托盘 @@ -107,6 +113,12 @@ chinesesimplified.DbBundledMissing=安装目录中缺少内置 PostgreSQL 文件 chinesesimplified.DbEnvMissing=数据库配置未写入 %ProgramData%\NetX\.env。 chinesesimplified.ReconfigureDb=重新配置数据库 chinesesimplified.UpgradeKeepDb=检测到已有 NetX 数据 - 将保留数据库配置(跳过向导)。 +chinesesimplified.ModePageCaption=安装模式 +chinesesimplified.ModePageDescription=检测到已有 NetX 安装。请选择更新或重装。 +chinesesimplified.ModeUpdate=更新现有安装(保留数据库配置) +chinesesimplified.ModeReinstall=重装并重新配置数据库(与首次安装相同) +chinesesimplified.ModeUpdateHint=仅替换程序文件,保留 ProgramData\.env 与数据库数据。 +chinesesimplified.ModeReinstallHint=再次显示数据库页。不会删除 ProgramData(除非卸载时选择删除)。 [Tasks] Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce @@ -203,6 +215,11 @@ var GCredKey: String; GSkipDbPage: Boolean; GIsUpgrade: Boolean; + GInstallMode: String; + ModePage: TWizardPage; + RbModeUpdate: TNewRadioButton; + RbModeReinstall: TNewRadioButton; + LblModeHint: TNewStaticText; GDbConfigured: Boolean; GPgToolsReady: Boolean; GDbConnOk: Boolean; @@ -265,6 +282,64 @@ begin Result := EnvHasDbMode(); end; +procedure SyncSkipDbFromInstallMode(); +begin + GSkipDbPage := (GInstallMode = 'update'); +end; + +procedure ApplyInstallModeFromChoice(); +begin + if (RbModeReinstall <> nil) and RbModeReinstall.Checked then + GInstallMode := 'reinstall' + else if GIsUpgrade then + GInstallMode := 'update' + else + GInstallMode := 'fresh'; + SyncSkipDbFromInstallMode(); +end; + +procedure ModeChoiceClick(Sender: TObject); +begin + ApplyInstallModeFromChoice(); + if LblModeHint = nil then + Exit; + if GInstallMode = 'reinstall' then + LblModeHint.Caption := ExpandConstant('{cm:ModeReinstallHint}') + else + LblModeHint.Caption := ExpandConstant('{cm:ModeUpdateHint}'); +end; + +function ResolveInstallModeFromParams(): Boolean; +{ Returns True if a CLI param forced the mode. } +var + Mode: String; +begin + Result := False; + Mode := LowerCase(Trim(ExpandConstant('{param:InstallMode|}'))); + if (Mode = 'update') or (Mode = 'upgrade') then + begin + GInstallMode := 'update'; + Result := True; + end + else if (Mode = 'reinstall') or (Mode = 'fresh') then + begin + GInstallMode := 'reinstall'; + Result := True; + end + else if ParamIsTruthy('SkipDbPage') then + begin + GInstallMode := 'update'; + Result := True; + end + else if ParamIsTruthy('ForceDbPage') then + begin + GInstallMode := 'reinstall'; + Result := True; + end; + if Result then + SyncSkipDbFromInstallMode(); +end; + procedure StopNetxBeforeFileReplace(); var ResultCode: Integer; @@ -538,18 +613,62 @@ begin GDbConfigured := False; GPgToolsReady := False; GDbConnOk := False; - { Upgrade / reinstall over a configured install: skip DB wizard and keep - ProgramData\.env / pgdata. Fresh install still shows the Database page. - Force wizard: /ForceDbPage=1 Force skip: /SkipDbPage=1 } + { Existing install: interactive Mode page (Update vs Reinstall). + Silent: /InstallMode=update|reinstall (or /SkipDbPage=1 /ForceDbPage=1). } GIsUpgrade := DetectExistingConfiguredInstall(); - if ParamIsTruthy('ForceDbPage') then - GSkipDbPage := False - else if ParamIsTruthy('SkipDbPage') then - GSkipDbPage := True - else - GSkipDbPage := GIsUpgrade; + ModePage := nil; + RbModeUpdate := nil; + RbModeReinstall := nil; + LblModeHint := nil; + if not ResolveInstallModeFromParams() then + begin + if GIsUpgrade then + GInstallMode := 'update' + else + GInstallMode := 'fresh'; + SyncSkipDbFromInstallMode(); + end; - DbPage := CreateCustomPage(wpSelectDir, + if GIsUpgrade and (not WizardSilent) then + begin + ModePage := CreateCustomPage(wpSelectDir, + ExpandConstant('{cm:ModePageCaption}'), + ExpandConstant('{cm:ModePageDescription}')); + + RbModeUpdate := TNewRadioButton.Create(ModePage); + RbModeUpdate.Parent := ModePage.Surface; + RbModeUpdate.Caption := ExpandConstant('{cm:ModeUpdate}'); + RbModeUpdate.Checked := (GInstallMode <> 'reinstall'); + RbModeUpdate.Top := ScaleY(8); + RbModeUpdate.Left := ScaleX(0); + RbModeUpdate.Width := ModePage.SurfaceWidth; + RbModeUpdate.OnClick := @ModeChoiceClick; + + RbModeReinstall := TNewRadioButton.Create(ModePage); + RbModeReinstall.Parent := ModePage.Surface; + RbModeReinstall.Caption := ExpandConstant('{cm:ModeReinstall}'); + RbModeReinstall.Checked := (GInstallMode = 'reinstall'); + RbModeReinstall.Top := RbModeUpdate.Top + ScaleY(28); + RbModeReinstall.Left := ScaleX(0); + RbModeReinstall.Width := ModePage.SurfaceWidth; + RbModeReinstall.OnClick := @ModeChoiceClick; + + LblModeHint := TNewStaticText.Create(ModePage); + LblModeHint.Parent := ModePage.Surface; + LblModeHint.Top := RbModeReinstall.Top + ScaleY(36); + LblModeHint.Left := ScaleX(0); + LblModeHint.Width := ModePage.SurfaceWidth; + LblModeHint.Height := ScaleY(60); + LblModeHint.AutoSize := False; + LblModeHint.WordWrap := True; + ModeChoiceClick(nil); + + DbPage := CreateCustomPage(ModePage.ID, + ExpandConstant('{cm:DbPageCaption}'), + ExpandConstant('{cm:DbPageDescription}')); + end + else + DbPage := CreateCustomPage(wpSelectDir, ExpandConstant('{cm:DbPageCaption}'), ExpandConstant('{cm:DbPageDescription}')); @@ -687,6 +806,12 @@ end; function ShouldSkipPage(PageID: Integer): Boolean; begin Result := False; + if (ModePage <> nil) and (PageID = ModePage.ID) then + begin + { Only interactive existing installs show the mode page. } + Result := (not GIsUpgrade) or WizardSilent; + Exit; + end; if (DbPage <> nil) and (PageID = DbPage.ID) then Result := GSkipDbPage; end; @@ -696,6 +821,11 @@ var ErrMsg: String; begin Result := True; + if (ModePage <> nil) and (CurPageID = ModePage.ID) then + begin + ApplyInstallModeFromChoice(); + Exit; + end; { Only the DB page runs validation. Other pages must always proceed (Tasks/Ready used to appear "dead" when CloseApplications hung). } if (DbPage = nil) or (CurPageID <> DbPage.ID) or GSkipDbPage then @@ -736,6 +866,14 @@ var begin Result := ''; NeedsRestart := False; + + if (ModePage <> nil) and (not WizardSilent) then + ApplyInstallModeFromChoice(); + + { Stop tray / API / bundled PG so Program Files can be replaced safely. } + if GSkipDbPage or GIsUpgrade or DirExists(ExpandConstant('{app}\packaging')) then + StopNetxBeforeFileReplace(); + if GSkipDbPage then Exit; @@ -897,12 +1035,31 @@ begin end; end; +procedure RepairNetxVenvAfterInstall(); +var + ResultCode: Integer; + RepairScript: String; + Params: String; +begin + RepairScript := ExpandConstant('{app}\packaging\repair_venv.ps1'); + if not FileExists(RepairScript) then + Exit; + Params := + '-NoProfile -WindowStyle Hidden -ExecutionPolicy Bypass -File "' + RepairScript + '"' + + ' -ProgramRoot "' + ExpandConstant('{app}') + '"' + + ' -DataRoot "' + ExpandConstant('{commonappdata}\NetX') + '"'; + Exec('powershell.exe', Params, ExpandConstant('{app}'), SW_HIDE, ewWaitUntilTerminated, ResultCode); +end; + procedure CurStepChanged(CurStep: TSetupStep); var UninstallKey: String; begin if CurStep = ssPostInstall then begin + { Always relink .venv -> local python/runtime (Setup is elevated). } + RepairNetxVenvAfterInstall(); + { Upgrade keeps existing .env / pgdata - do not re-run DB wizard. } if not GSkipDbPage then ApplyDatabaseConfig() diff --git a/packaging/manifest.example.json b/packaging/manifest.example.json index 36ac2c8..410b5ee 100644 --- a/packaging/manifest.example.json +++ b/packaging/manifest.example.json @@ -1,11 +1,11 @@ { "channel": "stable", - "latest": "0.4.11", + "latest": "0.4.12", "min_compatible": "0.3.0", - "notes_url": "https://github.com/hansjone/netx/releases/tag/v0.4.11", + "notes_url": "https://github.com/hansjone/netx/releases/tag/v0.4.12", "windows": { - "url": "https://github.com/hansjone/netx/releases/download/v0.4.11/NetX-Setup-0.4.11.exe", - "setup_url": "https://github.com/hansjone/netx/releases/download/v0.4.11/NetX-Setup-0.4.11.exe", + "url": "https://github.com/hansjone/netx/releases/download/v0.4.12/NetX-Setup-0.4.12.exe", + "setup_url": "https://github.com/hansjone/netx/releases/download/v0.4.12/NetX-Setup-0.4.12.exe", "package": "setup", "sha256": "", "size": 0 diff --git a/packaging/repair_venv.ps1 b/packaging/repair_venv.ps1 new file mode 100644 index 0000000..4172d3a --- /dev/null +++ b/packaging/repair_venv.ps1 @@ -0,0 +1,25 @@ +param( + [string]$ProgramRoot = "", + [string]$DataRoot = "" +) + +# Relink shipped .venv to local python/runtime (run elevated after Setup/update). +$ErrorActionPreference = "Stop" +. "$PSScriptRoot\_common.ps1" + +$prog = Get-NetxProgramRoot -Override $ProgramRoot +$data = Get-NetxDataRoot -ProgramRoot $prog -Override $DataRoot + +Write-Host "==> Repairing .venv under $prog" +if (Repair-NetxShippedVenv -ProgramRoot $prog) { + $venvPy = Join-Path $prog ".venv\Scripts\python.exe" + if (Test-NetxVenvRunnable -VenvPython $venvPy) { + Write-Host "==> .venv OK -> bundled python/runtime" -ForegroundColor Green + Get-Content (Join-Path $prog ".venv\pyvenv.cfg") + exit 0 + } + Write-Host "[ERR] pyvenv.cfg written but venv still not runnable" -ForegroundColor Red + exit 2 +} +Write-Host "[ERR] repair failed (missing runtime/.venv or access denied)" -ForegroundColor Red +exit 1 diff --git a/pyproject.toml b/pyproject.toml index 4278ff3..9263218 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "netx-ops" -version = "0.4.11" +version = "0.4.12" description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP" readme = "README.md" requires-python = ">=3.11" diff --git a/web/src/i18n/en.ts b/web/src/i18n/en.ts index a72a130..478d12a 100644 --- a/web/src/i18n/en.ts +++ b/web/src/i18n/en.ts @@ -500,13 +500,26 @@ const en = { runStatusFailed: "Failed", runStatusDoneSec: "Done {{s}}s", runProgress: "{{phase}} · sheet {{i}}/{{n}} · {{sheet}} · {{s}}s elapsed", + runSheetProgress: "sheet {{i}}/{{n}}", + runElapsed: "{{s}}s elapsed", runRowsLoaded: "Loaded {{side}} {{n}} rows", runRowsSqlCount: "DB count {{side}} {{n}} rows", runPersisting: "Wrote {{done}}/{{total}} rows", runEngineSql: "engine SQL", runEnginePython: "engine Python", runEngineNote: "reason {{note}}", + phaseQueued: "Queued", + phaseLoading: "Loading", + phaseSqlCount: "Counting", + phaseComparing: "Comparing", + phasePersisting: "Writing", + phasePersistingFail: "Writing fails", + phasePersistingOk: "Writing passes", + phaseDone: "Done", + phaseFailed: "Failed", + phaseCancelled: "Cancelled", sheetPending: "Pending", + colProgress: "Progress / result", ranWithDuration: "Compare finished ({{s}}s)", unchangedNotStored: "Success rows were counted but not stored. Set “Store success rows” to sample and re-run for spot-check.", unchangedSampleHint: "{{total}} success rows total; browsing a stratified sample of {{listed}}. Search any route for live source lookup. Pass rate uses all {{total}}.", diff --git a/web/src/i18n/zh.ts b/web/src/i18n/zh.ts index 5247a88..7369dfd 100644 --- a/web/src/i18n/zh.ts +++ b/web/src/i18n/zh.ts @@ -499,13 +499,26 @@ const zh = { runStatusFailed: "失败", runStatusDoneSec: "完成 {{s}}s", runProgress: "{{phase}} · 表 {{i}}/{{n}} · {{sheet}} · 已用 {{s}}s", + runSheetProgress: "表 {{i}}/{{n}}", + runElapsed: "已用 {{s}}s", runRowsLoaded: "已加载 {{side}} {{n}} 行", runRowsSqlCount: "库内统计 {{side}} {{n}} 行", runPersisting: "已写入 {{done}}/{{total}} 行", runEngineSql: "引擎 SQL", runEnginePython: "引擎 Python", runEngineNote: "原因 {{note}}", + phaseQueued: "排队中", + phaseLoading: "加载数据", + phaseSqlCount: "库内统计", + phaseComparing: "比对中", + phasePersisting: "写入结果", + phasePersistingFail: "写入失败行", + phasePersistingOk: "写入成功行", + phaseDone: "完成", + phaseFailed: "失败", + phaseCancelled: "已取消", sheetPending: "等待中", + colProgress: "进度 / 结果", ranWithDuration: "比对完成(耗时 {{s}} 秒)", unchangedNotStored: "成功行仅统计数量未落库。可在任务配置将「成功行保存」改为抽样后重新比对(抽查用)。", unchangedSampleHint: "成功共 {{total}} 条,明细抽样 {{listed}} 条(分层抽查)。要查任意路由请输入筛选条件——将按原表即时判定。通过率按全部 {{total}} 计。", diff --git a/web/src/index.css b/web/src/index.css index d70fc9d..d3e61d5 100644 --- a/web/src/index.css +++ b/web/src/index.css @@ -11700,10 +11700,9 @@ html.login-page--paused .login-page__flare { .bs-cmp-progress { display: flex; - flex-wrap: wrap; - align-items: baseline; - gap: 8px 14px; - padding: 8px 12px; + flex-direction: column; + gap: 6px; + padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(59, 130, 246, 0.35); background: rgba(37, 99, 235, 0.12); @@ -11716,11 +11715,147 @@ html.login-page--paused .login-page__flare { background: rgba(239, 68, 68, 0.12); } +.bs-cmp-progress.is-cancelled { + border-color: rgba(245, 158, 11, 0.4); + background: rgba(245, 158, 11, 0.1); +} + +.bs-cmp-progress__head { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 12px; +} + +.bs-cmp-progress__actions { + margin-left: auto; + display: flex; + align-items: center; + gap: 6px; +} + +.bs-cmp-progress__engine, +.bs-cmp-progress__elapsed { + font-size: 0.8125rem; +} + +.bs-cmp-progress__track, +.bs-cmp-run-prog__track { + height: 6px; + border-radius: 999px; + background: rgba(148, 163, 184, 0.28); + overflow: hidden; +} + +.bs-cmp-progress__fill, +.bs-cmp-run-prog__fill { + height: 100%; + width: 0; + border-radius: inherit; + background: linear-gradient(90deg, #2563eb, #60a5fa); + transition: width 0.35s ease; +} + +.bs-cmp-progress__fill.is-indeterminate, +.bs-cmp-run-prog__fill.is-indeterminate { + width: 36%; + animation: bs-cmp-prog-slide 1.25s ease-in-out infinite; +} + +@keyframes bs-cmp-prog-slide { + 0% { + transform: translateX(-120%); + } + 100% { + transform: translateX(320%); + } +} + +.bs-cmp-progress__meta { + font-size: 0.8125rem; + color: var(--bs-cmp-muted, #94a3b8); + line-height: 1.4; + word-break: break-word; +} + .bs-cmp-progress__msg { flex: 1 1 100%; font-size: 0.8125rem; } +/* Compare batch list (job detail → 对比批次) */ +.bs-cmp-runs { + display: flex; + flex-direction: column; + gap: 10px; + min-height: 260px; +} + +.bs-cmp-runs__toolbar { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + justify-content: space-between; + gap: 10px 14px; +} + +.bs-cmp-runs__hint { + margin: 0; + flex: 1 1 220px; + line-height: 1.45; +} + +.bs-cmp-runs__table tbody tr.is-selected td { + background: rgba(37, 99, 235, 0.08); +} + +.bs-cmp-runs__elapsed { + margin-top: 4px; + font-size: 0.75rem; + color: var(--muted, #64748b); + font-variant-numeric: tabular-nums; +} + +.bs-cmp-runs__sides { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 2px 6px; + font-size: 0.8125rem; + line-height: 1.35; + max-width: 22rem; +} + +.bs-cmp-runs__arrow { + color: var(--muted, #94a3b8); + flex: 0 0 auto; +} + +.bs-cmp-runs__summary { + font-size: 0.8125rem; + line-height: 1.4; + color: var(--ink, #334155); + max-width: 18rem; +} + +.bs-cmp-run-prog { + display: flex; + flex-direction: column; + gap: 5px; + min-width: 160px; + max-width: 22rem; +} + +.bs-cmp-run-prog__meta { + font-size: 0.75rem; + line-height: 1.35; + color: var(--muted, #64748b); + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + /* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */ .bs-cmp-board.is-fullscreen, .bs-cmp-board:fullscreen { diff --git a/web/src/pages/network/BizComparePage.tsx b/web/src/pages/network/BizComparePage.tsx index 4ebbf6d..5ed8927 100644 --- a/web/src/pages/network/BizComparePage.tsx +++ b/web/src/pages/network/BizComparePage.tsx @@ -336,6 +336,106 @@ function sheetLabel(s: { title?: string; sheet_id?: string; metric_id?: string } return String(s?.title || s?.sheet_id || s?.metric_id || "").trim() || "—"; } +type RunProgressInfo = { + phase?: string; + sheet_index?: number; + sheet_total?: number; + sheet_title?: string; + sheet_id?: string; + elapsed_ms?: number; + load_side?: string; + rows_loaded?: number; + engine?: string; + engine_note?: string; + persisted?: number; + persist_total?: number; +}; + +function localizeRunPhase(phase: string | undefined, t: (key: string) => string): string { + const p = String(phase || "").trim().toLowerCase(); + if (!p || p === "…") return "…"; + if (p.startsWith("persisting")) { + if (p.includes("fail")) return t("bizCompare.phasePersistingFail"); + if (p.includes("ok")) return t("bizCompare.phasePersistingOk"); + return t("bizCompare.phasePersisting"); + } + const map: Record = { + queued: "bizCompare.phaseQueued", + loading: "bizCompare.phaseLoading", + sql_count: "bizCompare.phaseSqlCount", + comparing: "bizCompare.phaseComparing", + done: "bizCompare.phaseDone", + failed: "bizCompare.phaseFailed", + cancelled: "bizCompare.phaseCancelled", + }; + const key = map[p]; + return key ? t(key) : String(phase); +} + +function runProgressPercent(prog: RunProgressInfo): number { + const total = Number(prog.sheet_total || 0); + const idx = Number(prog.sheet_index || 0); + if (total <= 0) return 8; + const phase = String(prog.phase || "").toLowerCase(); + if (phase === "queued") return 4; + if (phase === "done") return 100; + let base = Math.max(0, Math.min(idx, total)) / total; + const persistTotal = Number(prog.persist_total || 0); + const persisted = Number(prog.persisted || 0); + if (persistTotal > 0 && phase.startsWith("persisting")) { + const within = Math.min(1, persisted / persistTotal) * (1 / total); + base = Math.max(0, (idx - 1) / total) + within; + } else if (phase === "loading" || phase === "sql_count" || phase === "comparing") { + base = Math.max(0, (idx - 1) / total) + 0.35 / total; + } + return Math.max(4, Math.min(99, Math.round(base * 100))); +} + +function runProgressMetaLine( + prog: RunProgressInfo, + t: (key: string, vars?: Record) => string, +): string { + const parts: string[] = []; + const phase = localizeRunPhase(prog.phase, t); + if (phase && phase !== "…") parts.push(phase); + const total = Number(prog.sheet_total || 0); + if (total > 0) { + parts.push( + t("bizCompare.runSheetProgress", { + i: String(prog.sheet_index || 0), + n: String(total), + }), + ); + } + const title = String(prog.sheet_title || prog.sheet_id || "").trim(); + if (title) parts.push(title); + const rows = Number(prog.rows_loaded || 0); + if (rows > 0) { + const eng = String(prog.engine || "").toLowerCase(); + parts.push( + eng === "sql" + ? t("bizCompare.runRowsSqlCount", { + side: String(prog.load_side || "—"), + n: String(rows), + }) + : t("bizCompare.runRowsLoaded", { + side: String(prog.load_side || "—"), + n: String(rows), + }), + ); + } + const persistTotal = Number(prog.persist_total || 0); + if (String(prog.phase || "").startsWith("persisting") && persistTotal > 0) { + parts.push( + t("bizCompare.runPersisting", { + done: String(prog.persisted || 0), + total: String(persistTotal), + }), + ); + } + return parts.join(" · "); +} + function templateSheets(tpl?: Template | null): MetricSheet[] { if (!tpl) return []; if (tpl.metrics?.length) return tpl.metrics; @@ -1937,25 +2037,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage setBusy(false); } }; - const runProgress = (runDetail?.summary?.progress || {}) as { - phase?: string; - sheet_index?: number; - sheet_total?: number; - sheet_title?: string; - sheet_id?: string; - elapsed_ms?: number; - before_count?: number; - after_count?: number; - diff_rows?: number; - load_side?: string; - rows_loaded?: number; - engine?: string; - engine_note?: string; - persisted?: number; - persist_total?: number; - fail_rows?: number; - ok_rows?: number; - }; + const runProgress = (runDetail?.summary?.progress || {}) as RunProgressInfo; const runEngine = String(runProgress.engine || "").toLowerCase(); // Poll active compare runs so the modal can be closed and reopened safely. @@ -3009,10 +3091,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage renderJobForm() ) : jobDetailTab === "runs" ? (
-
-

- {t("bizCompare.runsHint")} -

+
+

{t("bizCompare.runsHint")}

) : ( -
- +
+
- + @@ -3059,17 +3139,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage Number(sum.removed || 0) + Number(sum.changed || 0); const ok = Number(sum.unchanged || 0); const durMs = Number(sum.duration_ms || 0); - const prog = ((r as any).progress || {}) as { - phase?: string; - sheet_index?: number; - sheet_total?: number; - sheet_title?: string; - rows_loaded?: number; - load_side?: string; - engine?: string; - engine_note?: string; - }; - const eng = String(prog.engine || "").toLowerCase(); + const prog = ((r as any).progress || + (r as any).summary?.progress || + {}) as RunProgressInfo; + const pct = active ? runProgressPercent(prog) : 100; + const elapsedSec = Math.round(Number(prog.elapsed_ms || 0) / 1000); const stLabel = st === "running" || st === "queued" ? t("bizCompare.runStatusRunning") @@ -3080,62 +3154,76 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage : t("bizCompare.runStatusDoneSec", { s: String(durMs > 0 ? Math.round(durMs / 1000) : 0), }); + const chipColor = active + ? "accent" + : st === "cancelled" + ? "warning" + : jobChipColor(st); + const meta = active ? runProgressMetaLine(prog, t) : ""; return ( - +
{t("bizCompare.colStatus")} {t("bizCompare.colTime")} {t("bizCompare.sidesTitle")}{t("bizCompare.colResult")}{t("bizCompare.colProgress")} {t("bizCompare.colActions")}
- {stLabel} - {active && (prog.phase || eng) ? ( -
- {eng === "sql" - ? t("bizCompare.runEngineSql") - : eng === "python" - ? t("bizCompare.runEnginePython") - : ""} - {eng && prog.engine_note - ? ` · ${prog.engine_note}` - : ""} - {prog.phase ? `${eng ? " · " : ""}${prog.phase}` : ""} - {prog.sheet_total - ? ` · ${prog.sheet_index || 0}/${prog.sheet_total}` - : ""} - {prog.sheet_title ? ` · ${prog.sheet_title}` : ""} - {Number(prog.rows_loaded || 0) > 0 - ? eng === "sql" - ? ` · ${prog.load_side || ""} ${prog.rows_loaded}` - : ` · ${prog.load_side || ""} ${prog.rows_loaded}` - : ""} -
- ) : null} - {(r as any).message ? ( -
- {String((r as any).message).slice(0, 80)} + {stLabel} + {active && elapsedSec > 0 ? ( +
+ {t("bizCompare.runElapsed", { s: String(elapsedSec) })}
) : null}
{when}{when} - {sideDeviceName(before)} {sideCollectTime(before)} - {" → "} - {sideDeviceName(after)} {sideCollectTime(after)} +
+ {sideDeviceName(before)} + {sideCollectTime(before)} + + → + + {sideDeviceName(after)} + {sideCollectTime(after)} +
- {active - ? "…" - : t("bizCompare.runBatchSummary", { - fail: String(fail), - ok: String(ok), - added: String(Number(sum.added || 0)), - })} + {active ? ( +
+
+
0 + ? { width: `${pct}%` } + : undefined + } + /> +
+
+ {meta || "…"} +
+
+ ) : ( +
+ {st === "failed" || st === "cancelled" + ? String((r as any).message || stLabel).slice(0, 96) + : t("bizCompare.runBatchSummary", { + fail: String(fail), + ok: String(ok), + added: String(Number(sum.added || 0)), + })} +
+ )}
-
+
+
+
+
+
0 + ? { width: `${runProgressPercent(runProgress)}%` } + : undefined + } + /> +
+
+ {runProgressMetaLine(runProgress, t)} +
) : null} {runDetail && runStatus === "failed" ? (
- {t("bizCompare.runStatusFailed")} - {String(runDetail.message || "")} +
+ {t("bizCompare.runStatusFailed")} +
+
{String(runDetail.message || "")}
+
+ ) : null} + {runDetail && runStatus === "cancelled" ? ( +
+
+ {t("bizCompare.runStatusCancelled")} +
+
+ {String(runDetail.message || t("bizCompare.runCancelled"))} +
) : null} {runDetail ? (