mirror of
https://github.com/hansjone/netx.git
synced 2026-10-20 01:50:45 +08:00
Compare commits
2 commits
372138d9a1
...
ee3ed26c88
| Author | SHA1 | Date | |
|---|---|---|---|
| ee3ed26c88 | |||
| ccd75a9c09 |
11 changed files with 649 additions and 166 deletions
|
|
@ -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).
|
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).
|
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).
|
4. Files → `%ProgramFiles%\NetX\` (program root).
|
||||||
5. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets).
|
5. Data → `%ProgramData%\NetX\` (`.env`, `pgdata`, spool, secrets).
|
||||||
6. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI** / **Reconfigure database**.
|
6. Start menu: **Start NetX** / **Stop NetX** / **Open NetX UI** / **Reconfigure database**.
|
||||||
|
|
||||||
Silent first install (bundled default): `/SILENT /DbMode=bundled`
|
Silent first install (bundled default): `/SILENT /DbMode=bundled`
|
||||||
Silent external: `/SILENT /DbMode=external /DbHost=... /DbPort=5432 /DbUser=... /DbPassword=... /DbName=...`
|
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`)
|
Silent update over existing data: `/VERYSILENT /NORESTART /InstallMode=update` (also `/SkipDbPage=1`)
|
||||||
Force DB wizard on an existing install: `/ForceDbPage=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.
|
Optional credential key (reuse encrypted NE passwords from another install): `/CredentialSecretKey=...` — omit to auto-generate.
|
||||||
|
|
||||||
## Database modes
|
## Database modes
|
||||||
|
|
@ -79,7 +81,7 @@ Existing Windows deploys that only set `NETX_DATABASE_URL` keep working: never s
|
||||||
|
|
||||||
## Manual update
|
## 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`):
|
Legacy/dev zip (only if you built with `-CreateZip`):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -404,9 +404,24 @@ function Repair-NetxShippedVenv {
|
||||||
"version = $ver",
|
"version = $ver",
|
||||||
"executable = $rtPyAbs"
|
"executable = $rtPyAbs"
|
||||||
)
|
)
|
||||||
Set-Content -LiteralPath $cfgPath -Value ($lines -join "`r`n") -Encoding ascii
|
$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
|
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 {
|
function Test-NetxVenvRunnable {
|
||||||
param([Parameter(Mandatory = $true)][string]$VenvPython)
|
param([Parameter(Mandatory = $true)][string]$VenvPython)
|
||||||
|
|
@ -446,7 +461,11 @@ function Ensure-NetxVenv {
|
||||||
$rtPy = Join-Path (Get-NetxBundledPythonRoot -ProgramRoot $ProgramRoot) "python.exe"
|
$rtPy = Join-Path (Get-NetxBundledPythonRoot -ProgramRoot $ProgramRoot) "python.exe"
|
||||||
|
|
||||||
if (Test-Path -LiteralPath $rtPy) {
|
if (Test-Path -LiteralPath $rtPy) {
|
||||||
|
try {
|
||||||
$null = Repair-NetxShippedVenv -ProgramRoot $ProgramRoot
|
$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) {
|
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
|
Write-Host "[WARN] Shipped .venv exists but Python cannot start (broken pyvenv.cfg or missing runtime)." -ForegroundColor Yellow
|
||||||
if (Test-Path -LiteralPath $rtPy) {
|
if (Test-Path -LiteralPath $rtPy) {
|
||||||
$null = Repair-NetxShippedVenv -ProgramRoot $ProgramRoot
|
$repaired = $false
|
||||||
if (Test-NetxVenvRunnable -VenvPython $venvPy) {
|
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
|
Write-Host "==> Repaired .venv to use bundled python/runtime" -ForegroundColor Green
|
||||||
return $venvPy
|
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) {
|
if (-not $ForcePip) {
|
||||||
throw "venv_not_runnable: reinstall NetX Setup (ships python/runtime + .venv) or run repair as admin"
|
throw "venv_not_runnable: reinstall NetX Setup (ships python/runtime + .venv) or run repair as admin"
|
||||||
|
|
|
||||||
|
|
@ -404,15 +404,22 @@ try {
|
||||||
|
|
||||||
$env:NETX_UPDATE_ELEVATED = "1"
|
$env:NETX_UPDATE_ELEVATED = "1"
|
||||||
if ($isSetup) {
|
if ($isSetup) {
|
||||||
# Silent Setup over an existing configured install skips the DB wizard
|
# Silent Setup update mode: skip DB wizard, keep ProgramData
|
||||||
# (see installer/netx.iss GSkipDbPage) and preserves ProgramData.
|
# (see installer/netx.iss /InstallMode=update).
|
||||||
Write-Host "==> Applying update via silent Setup.exe"
|
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
|
$sp = Start-Process -FilePath $pkgPath -ArgumentList $setupArgs -Wait -PassThru
|
||||||
if ($null -eq $sp.ExitCode -or $sp.ExitCode -ne 0) {
|
if ($null -eq $sp.ExitCode -or $sp.ExitCode -ne 0) {
|
||||||
$code = if ($null -eq $sp.ExitCode) { "null" } else { $sp.ExitCode }
|
$code = if ($null -eq $sp.ExitCode) { "null" } else { $sp.ExitCode }
|
||||||
throw "setup_update_failed: exit $code"
|
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"
|
Write-Host "==> Restarting NetX after Setup"
|
||||||
& (Join-Path $PSScriptRoot "start_netx_app.ps1") `
|
& (Join-Path $PSScriptRoot "start_netx_app.ps1") `
|
||||||
-ProgramRoot $prog -DataRoot $data -SkipBrowser
|
-ProgramRoot $prog -DataRoot $data -SkipBrowser
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
|
|
||||||
#define MyAppName "NetX"
|
#define MyAppName "NetX"
|
||||||
#ifndef MyAppVersion
|
#ifndef MyAppVersion
|
||||||
#define MyAppVersion "0.4.11"
|
#define MyAppVersion "0.4.12"
|
||||||
#endif
|
#endif
|
||||||
#define MyAppPublisher "NetX"
|
#define MyAppPublisher "NetX"
|
||||||
#define MyAppURL "https://github.com/hansjone/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.DbEnvMissing=Database configuration did not write %ProgramData%\NetX\.env.
|
||||||
english.ReconfigureDb=Reconfigure database
|
english.ReconfigureDb=Reconfigure database
|
||||||
english.UpgradeKeepDb=Existing NetX data found - database settings will be kept (no wizard).
|
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.CreateDesktopIcon=创建桌面快捷方式
|
||||||
chinesesimplified.SetupOptions=安装完成后:
|
chinesesimplified.SetupOptions=安装完成后:
|
||||||
chinesesimplified.StartTrayNow=立即启动 NetX 托盘
|
chinesesimplified.StartTrayNow=立即启动 NetX 托盘
|
||||||
|
|
@ -107,6 +113,12 @@ chinesesimplified.DbBundledMissing=安装目录中缺少内置 PostgreSQL 文件
|
||||||
chinesesimplified.DbEnvMissing=数据库配置未写入 %ProgramData%\NetX\.env。
|
chinesesimplified.DbEnvMissing=数据库配置未写入 %ProgramData%\NetX\.env。
|
||||||
chinesesimplified.ReconfigureDb=重新配置数据库
|
chinesesimplified.ReconfigureDb=重新配置数据库
|
||||||
chinesesimplified.UpgradeKeepDb=检测到已有 NetX 数据 - 将保留数据库配置(跳过向导)。
|
chinesesimplified.UpgradeKeepDb=检测到已有 NetX 数据 - 将保留数据库配置(跳过向导)。
|
||||||
|
chinesesimplified.ModePageCaption=安装模式
|
||||||
|
chinesesimplified.ModePageDescription=检测到已有 NetX 安装。请选择更新或重装。
|
||||||
|
chinesesimplified.ModeUpdate=更新现有安装(保留数据库配置)
|
||||||
|
chinesesimplified.ModeReinstall=重装并重新配置数据库(与首次安装相同)
|
||||||
|
chinesesimplified.ModeUpdateHint=仅替换程序文件,保留 ProgramData\.env 与数据库数据。
|
||||||
|
chinesesimplified.ModeReinstallHint=再次显示数据库页。不会删除 ProgramData(除非卸载时选择删除)。
|
||||||
|
|
||||||
[Tasks]
|
[Tasks]
|
||||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce
|
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: checkedonce
|
||||||
|
|
@ -203,6 +215,11 @@ var
|
||||||
GCredKey: String;
|
GCredKey: String;
|
||||||
GSkipDbPage: Boolean;
|
GSkipDbPage: Boolean;
|
||||||
GIsUpgrade: Boolean;
|
GIsUpgrade: Boolean;
|
||||||
|
GInstallMode: String;
|
||||||
|
ModePage: TWizardPage;
|
||||||
|
RbModeUpdate: TNewRadioButton;
|
||||||
|
RbModeReinstall: TNewRadioButton;
|
||||||
|
LblModeHint: TNewStaticText;
|
||||||
GDbConfigured: Boolean;
|
GDbConfigured: Boolean;
|
||||||
GPgToolsReady: Boolean;
|
GPgToolsReady: Boolean;
|
||||||
GDbConnOk: Boolean;
|
GDbConnOk: Boolean;
|
||||||
|
|
@ -265,6 +282,64 @@ begin
|
||||||
Result := EnvHasDbMode();
|
Result := EnvHasDbMode();
|
||||||
end;
|
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();
|
procedure StopNetxBeforeFileReplace();
|
||||||
var
|
var
|
||||||
ResultCode: Integer;
|
ResultCode: Integer;
|
||||||
|
|
@ -538,17 +613,61 @@ begin
|
||||||
GDbConfigured := False;
|
GDbConfigured := False;
|
||||||
GPgToolsReady := False;
|
GPgToolsReady := False;
|
||||||
GDbConnOk := False;
|
GDbConnOk := False;
|
||||||
{ Upgrade / reinstall over a configured install: skip DB wizard and keep
|
{ Existing install: interactive Mode page (Update vs Reinstall).
|
||||||
ProgramData\.env / pgdata. Fresh install still shows the Database page.
|
Silent: /InstallMode=update|reinstall (or /SkipDbPage=1 /ForceDbPage=1). }
|
||||||
Force wizard: /ForceDbPage=1 Force skip: /SkipDbPage=1 }
|
|
||||||
GIsUpgrade := DetectExistingConfiguredInstall();
|
GIsUpgrade := DetectExistingConfiguredInstall();
|
||||||
if ParamIsTruthy('ForceDbPage') then
|
ModePage := nil;
|
||||||
GSkipDbPage := False
|
RbModeUpdate := nil;
|
||||||
else if ParamIsTruthy('SkipDbPage') then
|
RbModeReinstall := nil;
|
||||||
GSkipDbPage := True
|
LblModeHint := nil;
|
||||||
|
if not ResolveInstallModeFromParams() then
|
||||||
|
begin
|
||||||
|
if GIsUpgrade then
|
||||||
|
GInstallMode := 'update'
|
||||||
else
|
else
|
||||||
GSkipDbPage := GIsUpgrade;
|
GInstallMode := 'fresh';
|
||||||
|
SyncSkipDbFromInstallMode();
|
||||||
|
end;
|
||||||
|
|
||||||
|
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,
|
DbPage := CreateCustomPage(wpSelectDir,
|
||||||
ExpandConstant('{cm:DbPageCaption}'),
|
ExpandConstant('{cm:DbPageCaption}'),
|
||||||
ExpandConstant('{cm:DbPageDescription}'));
|
ExpandConstant('{cm:DbPageDescription}'));
|
||||||
|
|
@ -687,6 +806,12 @@ end;
|
||||||
function ShouldSkipPage(PageID: Integer): Boolean;
|
function ShouldSkipPage(PageID: Integer): Boolean;
|
||||||
begin
|
begin
|
||||||
Result := False;
|
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
|
if (DbPage <> nil) and (PageID = DbPage.ID) then
|
||||||
Result := GSkipDbPage;
|
Result := GSkipDbPage;
|
||||||
end;
|
end;
|
||||||
|
|
@ -696,6 +821,11 @@ var
|
||||||
ErrMsg: String;
|
ErrMsg: String;
|
||||||
begin
|
begin
|
||||||
Result := True;
|
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
|
{ Only the DB page runs validation. Other pages must always proceed
|
||||||
(Tasks/Ready used to appear "dead" when CloseApplications hung). }
|
(Tasks/Ready used to appear "dead" when CloseApplications hung). }
|
||||||
if (DbPage = nil) or (CurPageID <> DbPage.ID) or GSkipDbPage then
|
if (DbPage = nil) or (CurPageID <> DbPage.ID) or GSkipDbPage then
|
||||||
|
|
@ -736,6 +866,14 @@ var
|
||||||
begin
|
begin
|
||||||
Result := '';
|
Result := '';
|
||||||
NeedsRestart := False;
|
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
|
if GSkipDbPage then
|
||||||
Exit;
|
Exit;
|
||||||
|
|
||||||
|
|
@ -897,12 +1035,31 @@ begin
|
||||||
end;
|
end;
|
||||||
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);
|
procedure CurStepChanged(CurStep: TSetupStep);
|
||||||
var
|
var
|
||||||
UninstallKey: String;
|
UninstallKey: String;
|
||||||
begin
|
begin
|
||||||
if CurStep = ssPostInstall then
|
if CurStep = ssPostInstall then
|
||||||
begin
|
begin
|
||||||
|
{ Always relink .venv -> local python/runtime (Setup is elevated). }
|
||||||
|
RepairNetxVenvAfterInstall();
|
||||||
|
|
||||||
{ Upgrade keeps existing .env / pgdata - do not re-run DB wizard. }
|
{ Upgrade keeps existing .env / pgdata - do not re-run DB wizard. }
|
||||||
if not GSkipDbPage then
|
if not GSkipDbPage then
|
||||||
ApplyDatabaseConfig()
|
ApplyDatabaseConfig()
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
{
|
{
|
||||||
"channel": "stable",
|
"channel": "stable",
|
||||||
"latest": "0.4.11",
|
"latest": "0.4.12",
|
||||||
"min_compatible": "0.3.0",
|
"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": {
|
"windows": {
|
||||||
"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.11/NetX-Setup-0.4.11.exe",
|
"setup_url": "https://github.com/hansjone/netx/releases/download/v0.4.12/NetX-Setup-0.4.12.exe",
|
||||||
"package": "setup",
|
"package": "setup",
|
||||||
"sha256": "",
|
"sha256": "",
|
||||||
"size": 0
|
"size": 0
|
||||||
|
|
|
||||||
25
packaging/repair_venv.ps1
Normal file
25
packaging/repair_venv.ps1
Normal file
|
|
@ -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
|
||||||
|
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "netx-ops"
|
name = "netx-ops"
|
||||||
version = "0.4.11"
|
version = "0.4.12"
|
||||||
description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP"
|
description = "netx operations tool: alarm-centric workflows with REST API and stdio MCP"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|
|
||||||
|
|
@ -500,13 +500,26 @@ const en = {
|
||||||
runStatusFailed: "Failed",
|
runStatusFailed: "Failed",
|
||||||
runStatusDoneSec: "Done {{s}}s",
|
runStatusDoneSec: "Done {{s}}s",
|
||||||
runProgress: "{{phase}} · sheet {{i}}/{{n}} · {{sheet}} · {{s}}s elapsed",
|
runProgress: "{{phase}} · sheet {{i}}/{{n}} · {{sheet}} · {{s}}s elapsed",
|
||||||
|
runSheetProgress: "sheet {{i}}/{{n}}",
|
||||||
|
runElapsed: "{{s}}s elapsed",
|
||||||
runRowsLoaded: "Loaded {{side}} {{n}} rows",
|
runRowsLoaded: "Loaded {{side}} {{n}} rows",
|
||||||
runRowsSqlCount: "DB count {{side}} {{n}} rows",
|
runRowsSqlCount: "DB count {{side}} {{n}} rows",
|
||||||
runPersisting: "Wrote {{done}}/{{total}} rows",
|
runPersisting: "Wrote {{done}}/{{total}} rows",
|
||||||
runEngineSql: "engine SQL",
|
runEngineSql: "engine SQL",
|
||||||
runEnginePython: "engine Python",
|
runEnginePython: "engine Python",
|
||||||
runEngineNote: "reason {{note}}",
|
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",
|
sheetPending: "Pending",
|
||||||
|
colProgress: "Progress / result",
|
||||||
ranWithDuration: "Compare finished ({{s}}s)",
|
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.",
|
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}}.",
|
unchangedSampleHint: "{{total}} success rows total; browsing a stratified sample of {{listed}}. Search any route for live source lookup. Pass rate uses all {{total}}.",
|
||||||
|
|
|
||||||
|
|
@ -499,13 +499,26 @@ const zh = {
|
||||||
runStatusFailed: "失败",
|
runStatusFailed: "失败",
|
||||||
runStatusDoneSec: "完成 {{s}}s",
|
runStatusDoneSec: "完成 {{s}}s",
|
||||||
runProgress: "{{phase}} · 表 {{i}}/{{n}} · {{sheet}} · 已用 {{s}}s",
|
runProgress: "{{phase}} · 表 {{i}}/{{n}} · {{sheet}} · 已用 {{s}}s",
|
||||||
|
runSheetProgress: "表 {{i}}/{{n}}",
|
||||||
|
runElapsed: "已用 {{s}}s",
|
||||||
runRowsLoaded: "已加载 {{side}} {{n}} 行",
|
runRowsLoaded: "已加载 {{side}} {{n}} 行",
|
||||||
runRowsSqlCount: "库内统计 {{side}} {{n}} 行",
|
runRowsSqlCount: "库内统计 {{side}} {{n}} 行",
|
||||||
runPersisting: "已写入 {{done}}/{{total}} 行",
|
runPersisting: "已写入 {{done}}/{{total}} 行",
|
||||||
runEngineSql: "引擎 SQL",
|
runEngineSql: "引擎 SQL",
|
||||||
runEnginePython: "引擎 Python",
|
runEnginePython: "引擎 Python",
|
||||||
runEngineNote: "原因 {{note}}",
|
runEngineNote: "原因 {{note}}",
|
||||||
|
phaseQueued: "排队中",
|
||||||
|
phaseLoading: "加载数据",
|
||||||
|
phaseSqlCount: "库内统计",
|
||||||
|
phaseComparing: "比对中",
|
||||||
|
phasePersisting: "写入结果",
|
||||||
|
phasePersistingFail: "写入失败行",
|
||||||
|
phasePersistingOk: "写入成功行",
|
||||||
|
phaseDone: "完成",
|
||||||
|
phaseFailed: "失败",
|
||||||
|
phaseCancelled: "已取消",
|
||||||
sheetPending: "等待中",
|
sheetPending: "等待中",
|
||||||
|
colProgress: "进度 / 结果",
|
||||||
ranWithDuration: "比对完成(耗时 {{s}} 秒)",
|
ranWithDuration: "比对完成(耗时 {{s}} 秒)",
|
||||||
unchangedNotStored: "成功行仅统计数量未落库。可在任务配置将「成功行保存」改为抽样后重新比对(抽查用)。",
|
unchangedNotStored: "成功行仅统计数量未落库。可在任务配置将「成功行保存」改为抽样后重新比对(抽查用)。",
|
||||||
unchangedSampleHint: "成功共 {{total}} 条,明细抽样 {{listed}} 条(分层抽查)。要查任意路由请输入筛选条件——将按原表即时判定。通过率按全部 {{total}} 计。",
|
unchangedSampleHint: "成功共 {{total}} 条,明细抽样 {{listed}} 条(分层抽查)。要查任意路由请输入筛选条件——将按原表即时判定。通过率按全部 {{total}} 计。",
|
||||||
|
|
|
||||||
|
|
@ -11700,10 +11700,9 @@ html.login-page--paused .login-page__flare {
|
||||||
|
|
||||||
.bs-cmp-progress {
|
.bs-cmp-progress {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-wrap: wrap;
|
flex-direction: column;
|
||||||
align-items: baseline;
|
gap: 6px;
|
||||||
gap: 8px 14px;
|
padding: 10px 12px;
|
||||||
padding: 8px 12px;
|
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
border: 1px solid rgba(59, 130, 246, 0.35);
|
border: 1px solid rgba(59, 130, 246, 0.35);
|
||||||
background: rgba(37, 99, 235, 0.12);
|
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);
|
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 {
|
.bs-cmp-progress__msg {
|
||||||
flex: 1 1 100%;
|
flex: 1 1 100%;
|
||||||
font-size: 0.8125rem;
|
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) */
|
/* Native :fullscreen + CSS immersive fallback (class only when FS API blocked) */
|
||||||
.bs-cmp-board.is-fullscreen,
|
.bs-cmp-board.is-fullscreen,
|
||||||
.bs-cmp-board:fullscreen {
|
.bs-cmp-board:fullscreen {
|
||||||
|
|
|
||||||
|
|
@ -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() || "—";
|
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<string, string> = {
|
||||||
|
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>) => 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[] {
|
function templateSheets(tpl?: Template | null): MetricSheet[] {
|
||||||
if (!tpl) return [];
|
if (!tpl) return [];
|
||||||
if (tpl.metrics?.length) return tpl.metrics;
|
if (tpl.metrics?.length) return tpl.metrics;
|
||||||
|
|
@ -1937,25 +2037,7 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
setBusy(false);
|
setBusy(false);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const runProgress = (runDetail?.summary?.progress || {}) as {
|
const runProgress = (runDetail?.summary?.progress || {}) as RunProgressInfo;
|
||||||
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 runEngine = String(runProgress.engine || "").toLowerCase();
|
const runEngine = String(runProgress.engine || "").toLowerCase();
|
||||||
|
|
||||||
// Poll active compare runs so the modal can be closed and reopened safely.
|
// 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()
|
renderJobForm()
|
||||||
) : jobDetailTab === "runs" ? (
|
) : jobDetailTab === "runs" ? (
|
||||||
<div className="bs-cmp-runs">
|
<div className="bs-cmp-runs">
|
||||||
<div className="btn-row" style={{ marginBottom: 8, justifyContent: "space-between" }}>
|
<div className="bs-cmp-runs__toolbar">
|
||||||
<p className="muted bm-hint" style={{ margin: 0 }}>
|
<p className="muted bm-hint bs-cmp-runs__hint">{t("bizCompare.runsHint")}</p>
|
||||||
{t("bizCompare.runsHint")}
|
|
||||||
</p>
|
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="primary"
|
variant="primary"
|
||||||
|
|
@ -3025,14 +3105,14 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
{!runs.length ? (
|
{!runs.length ? (
|
||||||
<div className="pt-list-empty">{t("bizCompare.noRuns")}</div>
|
<div className="pt-list-empty">{t("bizCompare.noRuns")}</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="table-wrap">
|
<div className="pt-list-table-wrap">
|
||||||
<table className="data-table">
|
<table className="data-table pt-list-table bs-cmp-runs__table">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>{t("bizCompare.colStatus")}</th>
|
<th>{t("bizCompare.colStatus")}</th>
|
||||||
<th>{t("bizCompare.colTime")}</th>
|
<th>{t("bizCompare.colTime")}</th>
|
||||||
<th>{t("bizCompare.sidesTitle")}</th>
|
<th>{t("bizCompare.sidesTitle")}</th>
|
||||||
<th>{t("bizCompare.colResult")}</th>
|
<th>{t("bizCompare.colProgress")}</th>
|
||||||
<th>{t("bizCompare.colActions")}</th>
|
<th>{t("bizCompare.colActions")}</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -3059,17 +3139,11 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
Number(sum.removed || 0) + Number(sum.changed || 0);
|
Number(sum.removed || 0) + Number(sum.changed || 0);
|
||||||
const ok = Number(sum.unchanged || 0);
|
const ok = Number(sum.unchanged || 0);
|
||||||
const durMs = Number(sum.duration_ms || 0);
|
const durMs = Number(sum.duration_ms || 0);
|
||||||
const prog = ((r as any).progress || {}) as {
|
const prog = ((r as any).progress ||
|
||||||
phase?: string;
|
(r as any).summary?.progress ||
|
||||||
sheet_index?: number;
|
{}) as RunProgressInfo;
|
||||||
sheet_total?: number;
|
const pct = active ? runProgressPercent(prog) : 100;
|
||||||
sheet_title?: string;
|
const elapsedSec = Math.round(Number(prog.elapsed_ms || 0) / 1000);
|
||||||
rows_loaded?: number;
|
|
||||||
load_side?: string;
|
|
||||||
engine?: string;
|
|
||||||
engine_note?: string;
|
|
||||||
};
|
|
||||||
const eng = String(prog.engine || "").toLowerCase();
|
|
||||||
const stLabel =
|
const stLabel =
|
||||||
st === "running" || st === "queued"
|
st === "running" || st === "queued"
|
||||||
? t("bizCompare.runStatusRunning")
|
? t("bizCompare.runStatusRunning")
|
||||||
|
|
@ -3080,62 +3154,76 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
: t("bizCompare.runStatusDoneSec", {
|
: t("bizCompare.runStatusDoneSec", {
|
||||||
s: String(durMs > 0 ? Math.round(durMs / 1000) : 0),
|
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 (
|
return (
|
||||||
<tr key={String(r.id)} className={selected ? "is-selected" : undefined}>
|
<tr key={String(r.id)} className={selected ? "is-selected" : undefined}>
|
||||||
<td>
|
<td>
|
||||||
<NmStatusChip color={jobChipColor(st)}>{stLabel}</NmStatusChip>
|
<NmStatusChip color={chipColor}>{stLabel}</NmStatusChip>
|
||||||
{active && (prog.phase || eng) ? (
|
{active && elapsedSec > 0 ? (
|
||||||
<div className="muted" style={{ fontSize: "0.75rem", marginTop: 4 }}>
|
<div className="bs-cmp-runs__elapsed">
|
||||||
{eng === "sql"
|
{t("bizCompare.runElapsed", { s: String(elapsedSec) })}
|
||||||
? 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}`
|
|
||||||
: ""}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{(r as any).message ? (
|
</td>
|
||||||
|
<td className="pt-list-time">{when}</td>
|
||||||
|
<td>
|
||||||
|
<div className="bs-cmp-runs__sides" title={`${sideDeviceName(before)} → ${sideDeviceName(after)}`}>
|
||||||
|
<span>{sideDeviceName(before)}</span>
|
||||||
|
<span className="muted">{sideCollectTime(before)}</span>
|
||||||
|
<span className="bs-cmp-runs__arrow" aria-hidden>
|
||||||
|
→
|
||||||
|
</span>
|
||||||
|
<span>{sideDeviceName(after)}</span>
|
||||||
|
<span className="muted">{sideCollectTime(after)}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{active ? (
|
||||||
|
<div className="bs-cmp-run-prog">
|
||||||
<div
|
<div
|
||||||
className="muted"
|
className="bs-cmp-run-prog__track"
|
||||||
style={{ fontSize: "0.75rem", marginTop: 2 }}
|
role="progressbar"
|
||||||
title={String((r as any).message)}
|
aria-valuenow={pct}
|
||||||
|
aria-valuemin={0}
|
||||||
|
aria-valuemax={100}
|
||||||
>
|
>
|
||||||
{String((r as any).message).slice(0, 80)}
|
<div
|
||||||
|
className={`bs-cmp-run-prog__fill${
|
||||||
|
Number(prog.sheet_total || 0) <= 0 ? " is-indeterminate" : ""
|
||||||
|
}`}
|
||||||
|
style={
|
||||||
|
Number(prog.sheet_total || 0) > 0
|
||||||
|
? { width: `${pct}%` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
<div className="bs-cmp-run-prog__meta" title={meta}>
|
||||||
</td>
|
{meta || "…"}
|
||||||
<td>{when}</td>
|
</div>
|
||||||
<td>
|
</div>
|
||||||
{sideDeviceName(before)} {sideCollectTime(before)}
|
) : (
|
||||||
{" → "}
|
<div className="bs-cmp-runs__summary">
|
||||||
{sideDeviceName(after)} {sideCollectTime(after)}
|
{st === "failed" || st === "cancelled"
|
||||||
</td>
|
? String((r as any).message || stLabel).slice(0, 96)
|
||||||
<td>
|
|
||||||
{active
|
|
||||||
? "…"
|
|
||||||
: t("bizCompare.runBatchSummary", {
|
: t("bizCompare.runBatchSummary", {
|
||||||
fail: String(fail),
|
fail: String(fail),
|
||||||
ok: String(ok),
|
ok: String(ok),
|
||||||
added: String(Number(sum.added || 0)),
|
added: String(Number(sum.added || 0)),
|
||||||
})}
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<div className="btn-row">
|
<div className="pt-list-actions">
|
||||||
<Button
|
<Button
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="secondary"
|
variant="primary"
|
||||||
isDisabled={busy || active}
|
isDisabled={busy || active}
|
||||||
onPress={() => void loadRun(String(r.id))}
|
onPress={() => void loadRun(String(r.id))}
|
||||||
>
|
>
|
||||||
|
|
@ -3259,6 +3347,8 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
? t("bizCompare.runStatusRunning")
|
? t("bizCompare.runStatusRunning")
|
||||||
: st === "failed"
|
: st === "failed"
|
||||||
? t("bizCompare.runStatusFailed")
|
? t("bizCompare.runStatusFailed")
|
||||||
|
: st === "cancelled"
|
||||||
|
? t("bizCompare.runStatusCancelled")
|
||||||
: durMs > 0
|
: durMs > 0
|
||||||
? t("bizCompare.runStatusDoneSec", {
|
? t("bizCompare.runStatusDoneSec", {
|
||||||
s: String(Math.round(durMs / 1000)),
|
s: String(Math.round(durMs / 1000)),
|
||||||
|
|
@ -3315,9 +3405,10 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
|
|
||||||
{runDetail && runIsActive ? (
|
{runDetail && runIsActive ? (
|
||||||
<div className="bs-cmp-progress" role="status" aria-live="polite">
|
<div className="bs-cmp-progress" role="status" aria-live="polite">
|
||||||
<strong>{t("bizCompare.runStatusRunning")}</strong>
|
<div className="bs-cmp-progress__head">
|
||||||
|
<NmStatusChip color="accent">{t("bizCompare.runStatusRunning")}</NmStatusChip>
|
||||||
{runEngine === "sql" || runEngine === "python" ? (
|
{runEngine === "sql" || runEngine === "python" ? (
|
||||||
<span className="muted">
|
<span className="muted bs-cmp-progress__engine">
|
||||||
{runEngine === "sql"
|
{runEngine === "sql"
|
||||||
? t("bizCompare.runEngineSql")
|
? t("bizCompare.runEngineSql")
|
||||||
: t("bizCompare.runEnginePython")}
|
: t("bizCompare.runEnginePython")}
|
||||||
|
|
@ -3328,48 +3419,61 @@ export function BizComparePage({ pageMode = "all" }: { pageMode?: BizComparePage
|
||||||
: ""}
|
: ""}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
<span className="muted">
|
<span className="muted bs-cmp-progress__elapsed">
|
||||||
{t("bizCompare.runProgress", {
|
{t("bizCompare.runElapsed", {
|
||||||
phase: String(runProgress.phase || "…"),
|
|
||||||
i: String(runProgress.sheet_index || 0),
|
|
||||||
n: String(runProgress.sheet_total || 0),
|
|
||||||
sheet: String(
|
|
||||||
runProgress.sheet_title || runProgress.sheet_id || "—",
|
|
||||||
),
|
|
||||||
s: String(Math.round(Number(runProgress.elapsed_ms || 0) / 1000)),
|
s: String(Math.round(Number(runProgress.elapsed_ms || 0) / 1000)),
|
||||||
})}
|
})}
|
||||||
</span>
|
</span>
|
||||||
{Number(runProgress.rows_loaded || 0) > 0 ? (
|
<div className="bs-cmp-progress__actions">
|
||||||
<span className="muted">
|
<Button
|
||||||
{runEngine === "sql"
|
size="sm"
|
||||||
? t("bizCompare.runRowsSqlCount", {
|
variant="danger"
|
||||||
side: String(runProgress.load_side || "—"),
|
isDisabled={busy}
|
||||||
n: String(runProgress.rows_loaded || 0),
|
onPress={() => void cancelRun(String(runDetail.id))}
|
||||||
})
|
>
|
||||||
: t("bizCompare.runRowsLoaded", {
|
{t("bizCompare.cancelRun")}
|
||||||
side: String(runProgress.load_side || "—"),
|
</Button>
|
||||||
n: String(runProgress.rows_loaded || 0),
|
</div>
|
||||||
})}
|
</div>
|
||||||
</span>
|
<div
|
||||||
) : null}
|
className="bs-cmp-progress__track"
|
||||||
{String(runProgress.phase || "").startsWith("persisting") &&
|
role="progressbar"
|
||||||
Number(runProgress.persist_total || 0) > 0 ? (
|
aria-valuenow={runProgressPercent(runProgress)}
|
||||||
<span className="muted">
|
aria-valuemin={0}
|
||||||
{t("bizCompare.runPersisting", {
|
aria-valuemax={100}
|
||||||
done: String(runProgress.persisted || 0),
|
>
|
||||||
total: String(runProgress.persist_total || 0),
|
<div
|
||||||
})}
|
className={`bs-cmp-progress__fill${
|
||||||
</span>
|
Number(runProgress.sheet_total || 0) <= 0 ? " is-indeterminate" : ""
|
||||||
) : null}
|
}`}
|
||||||
{runDetail.message ? (
|
style={
|
||||||
<span className="muted bs-cmp-progress__msg">{String(runDetail.message)}</span>
|
Number(runProgress.sheet_total || 0) > 0
|
||||||
) : null}
|
? { width: `${runProgressPercent(runProgress)}%` }
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="bs-cmp-progress__meta">
|
||||||
|
{runProgressMetaLine(runProgress, t)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{runDetail && runStatus === "failed" ? (
|
{runDetail && runStatus === "failed" ? (
|
||||||
<div className="bs-cmp-progress is-failed" role="alert">
|
<div className="bs-cmp-progress is-failed" role="alert">
|
||||||
<strong>{t("bizCompare.runStatusFailed")}</strong>
|
<div className="bs-cmp-progress__head">
|
||||||
<span>{String(runDetail.message || "")}</span>
|
<NmStatusChip color="danger">{t("bizCompare.runStatusFailed")}</NmStatusChip>
|
||||||
|
</div>
|
||||||
|
<div className="bs-cmp-progress__meta">{String(runDetail.message || "")}</div>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{runDetail && runStatus === "cancelled" ? (
|
||||||
|
<div className="bs-cmp-progress is-cancelled" role="status">
|
||||||
|
<div className="bs-cmp-progress__head">
|
||||||
|
<NmStatusChip color="warning">{t("bizCompare.runStatusCancelled")}</NmStatusChip>
|
||||||
|
</div>
|
||||||
|
<div className="bs-cmp-progress__meta">
|
||||||
|
{String(runDetail.message || t("bizCompare.runCancelled"))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{runDetail ? (
|
{runDetail ? (
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue