diff --git a/CLAUDE.md b/CLAUDE.md index ec862441..da20a341 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Diese Datei ist der Index; die Tiefe liegt in `docs/`: - **Backend:** ASP.NET Core Web API, .NET 10, Windows-only (`net10.0-windows`) - **Datenbank:** PostgreSQL (default) / SQL Server (`Database:Provider` = `postgres` | `sqlserver`). SQLite nur als Test-In-Memory-Backend. -- **Remote Execution:** PowerShell SDK / WinRM, agentless. `Remote:Provider`: `winrm` (default) | `noop` (`noop` muss per `Remote:AllowNoop=true` bzw. `NODEPILOT_ALLOW_NOOP_REMOTE=1` quittiert werden, sonst Boot-Abbruch) +- **Remote Execution:** PowerShell SDK / WinRM, agentless. `Remote:Provider`: `winrm` (default) | `noop` (`noop` muss per `Remote:AllowNoop=true` bzw. `NODEPILOT_ALLOW_NOOP_REMOTE=1` quittiert werden, sonst Boot-Abbruch). Engine-local (In-Proc-Pool): implizite WinPS-Kompatibilität **deaktiviert** (Desktop-only-Module → lauter Fehler statt `powershell.exe -s`-Session-Leak; `Microsoft.PowerShell.Archive` gebündelt) — Details `docs/claude-reference.md` + `docs/performance-improvements.md` - **Real-time:** SignalR (`/hubs/execution`) - **Logging:** Serilog. Format via `Logging:Format`: `text`|`cmtrace`|`json`|`ecs-json` (ECS 1.x für SIEM, siehe `docs/siem-logging.md`). Support-Log: File + DB-Projektion - **MCP-Server (opt-in):** `nodepilot-mcp` (stdio) — AI-Agent steuert/editiert Workflows über 99 Tools, HTTP-only gegen die REST-API @@ -251,7 +251,7 @@ Standard-Invocations (`dotnet build|test`, in `src/nodepilot-ui` die `package.js **E2E (Playwright):** hermetische Specs in `src/nodepilot-ui/e2e/`, alle APIs gemockt (kein Backend/Postgres nötig). Konventionen: `src/nodepilot-ui/CLAUDE.md` + `src/nodepilot-ui/e2e/README.md`. -**Desktop-Shell:** `src/nodepilot-desktop` hat eine eigene vitest-Suite (node-Env) für die reine Logik — `config.ts` (desktop.json-Handoff-Validierung) + `security.ts` (Cert-Pinning, Navigations-Containment). `npm run test:run`; eigener CI-Job `desktop`. +**Desktop-Shell:** `src/nodepilot-desktop` hat eine eigene vitest-Suite (node-Env) für die reine Logik — `config.ts` (desktop.json-Handoff-Validierung), `security.ts` (Cert-Pinning, Navigations-Containment) + `skins.ts` (Skin-Icon-Auflösung aus der Favicon-Meldung der SPA). `npm run test:run`; eigener CI-Job `desktop`. **Nightly:** Windows-Task `NodePilot Nightly Tests` (täglich 22:00) fährt via `scripts/nightly-tests.ps1` alle vier Suiten (je 1× Retry bei Flake), Report nach `C:\temp\nodepilot-nightly\` (+ `latest.md`). Das Skript gibt vorm Rebuild Port 5000 frei + killt verwaiste `testhost`-Prozesse. Manuell: `powershell -File scripts/nightly-tests.ps1`; Zeit ändern: `scripts/register-nightly-task.ps1 -Time HH:mm`. @@ -336,4 +336,4 @@ Getrennt vom Workflow-Export: voller DR-Snapshot der Konfiguration (Workflows+Fo Produktiv-Rollout über `deploy/`-Skripte — Claude führt sie **nicht** aus. Vollständige Doku: `deploy/README.md`. Architektur (gMSA, Kestrel-HTTPS, Install-Dir-Split, Config-Keys, Stolperfallen): siehe `docs/claude-reference.md`. -**Desktop-App (Electron, `deploy/desktop/`):** zweites Shipping-Ziel — offline Win-11-x64-Installer, alles als Boot-Start-Dienste. Posture `Deployment:Mode` (`Server`|`Desktop`, default `Server`): Desktop relaxiert **nur** loopback-DB-TLS + Kestrel-`ListenLocalhost` + 120s-Postgres-Wait vor dem Migration-Bootstrap; Rest bleibt Production-gehärtet. Volle Doku (Architektur, Dienste-Identitäten, First-Run-Admin-Handoff): `deploy/desktop/README.md`. +**Desktop-App (Electron, `deploy/desktop/`):** zweites Shipping-Ziel — offline Win-11-x64-Installer, alles als Boot-Start-Dienste. Posture `Deployment:Mode` (`Server`|`Desktop`, default `Server`): Desktop relaxiert **nur** loopback-DB-TLS + Kestrel-`ListenLocalhost` + 120s-Postgres-Wait vor dem Migration-Bootstrap; Rest bleibt Production-gehärtet. Icons kommen aus `scripts/generate-desktop-icons.ps1` (Default blau, Fenster-/Tray-Icon folgt zur Laufzeit dem SPA-Skin über `page-favicon-updated`). Volle Doku (Architektur, Dienste-Identitäten, First-Run-Admin-Handoff): `deploy/desktop/README.md`. diff --git a/Directory.Build.targets b/Directory.Build.targets new file mode 100644 index 00000000..89a7a7af --- /dev/null +++ b/Directory.Build.targets @@ -0,0 +1,40 @@ + + + + + $(MSBuildThisFileDirectory)src\NodePilot.Engine\PowerShell\powershell.config.json + + + + + + + + + + + diff --git a/deploy/desktop/Build-DesktopInstaller.ps1 b/deploy/desktop/Build-DesktopInstaller.ps1 index 19dd44c9..b5b4c5fe 100644 --- a/deploy/desktop/Build-DesktopInstaller.ps1 +++ b/deploy/desktop/Build-DesktopInstaller.ps1 @@ -34,7 +34,6 @@ $Stage = Join-Path $OutputRoot 'stage' $DesktopDir = Join-Path $RepoRoot 'src\nodepilot-desktop' $UiDir = Join-Path $RepoRoot 'src\nodepilot-ui' $ApiCsproj = Join-Path $RepoRoot 'src\NodePilot.Api\NodePilot.Api.csproj' -$AssetsDir = Join-Path $DesktopDir 'assets' function Write-Step([string] $m) { Write-Host "==> $m" -ForegroundColor Cyan } function Assert-Tool([string] $name, [string] $probe) { @@ -63,85 +62,12 @@ if (Test-Path -LiteralPath $Stage) { Remove-Item -LiteralPath $Stage -Recurse -F New-Item -ItemType Directory -Force -Path $Stage, $OutputRoot | Out-Null # --- icons ----------------------------------------------------------------------------------- -# Derived from the tracked brand asset so the installer, Start-Menu entry, taskbar and tray all +# Derived from the tracked brand assets so the installer, Start-Menu entry, taskbar and tray all # show the real NodePilot logo. The generated files stay out of git (assets/.gitignore); the -# SOURCE is versioned, which is why a clean clone can always rebuild them. -Write-Step 'Generating application icons from the brand asset' -New-Item -ItemType Directory -Force -Path $AssetsDir | Out-Null -Add-Type -AssemblyName System.Drawing - -$brandIcon = Join-Path $UiDir 'public\appicon.png' -if (-not (Test-Path -LiteralPath $brandIcon)) { - throw "Brand icon not found: $brandIcon. The desktop package must not ship a placeholder icon." -} -$brandBitmap = [System.Drawing.Image]::FromFile($brandIcon) - -function New-ScaledBitmap([System.Drawing.Image] $source, [int] $size) { - # 32bpp ARGB + HighQualityBicubic keeps the logo's transparency and edges intact when the - # 635x635 source is reduced to icon sizes. - $bmp = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) - $g = [System.Drawing.Graphics]::FromImage($bmp) - $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic - $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality - $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality - $g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality - $g.Clear([System.Drawing.Color]::Transparent) - $g.DrawImage($source, (New-Object System.Drawing.Rectangle(0, 0, $size, $size))) - $g.Dispose() - return $bmp -} - -# Multi-resolution ICO, written by hand: System.Drawing's Icon.Save(GetHicon()) emits a single -# resolution, which Windows then rescales badly in Explorer and the taskbar. The ICO container is -# a 6-byte header + one 16-byte directory entry per image + the PNG payloads (PNG-compressed -# entries are valid since Vista and keep the file small). -function Write-MultiSizeIco([System.Drawing.Image] $source, [int[]] $sizes, [string] $path) { - $payloads = foreach ($s in $sizes) { - $bmp = New-ScaledBitmap $source $s - $ms = New-Object System.IO.MemoryStream - $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) - $bmp.Dispose() - [pscustomobject]@{ Size = $s; Bytes = $ms.ToArray() } - $ms.Dispose() - } - - $fs = [System.IO.File]::Create($path) - $bw = New-Object System.IO.BinaryWriter($fs) - try { - $bw.Write([uint16]0) # reserved - $bw.Write([uint16]1) # type: icon - $bw.Write([uint16]$payloads.Count) - $offset = 6 + (16 * $payloads.Count) # header + directory - foreach ($p in $payloads) { - # 256 is encoded as 0 in the single-byte width/height fields. - $dim = if ($p.Size -ge 256) { 0 } else { $p.Size } - $bw.Write([byte]$dim); $bw.Write([byte]$dim) - $bw.Write([byte]0) # palette colours (0 = truecolour) - $bw.Write([byte]0) # reserved - $bw.Write([uint16]1) # colour planes - $bw.Write([uint16]32) # bits per pixel - $bw.Write([uint32]$p.Bytes.Length) - $bw.Write([uint32]$offset) - $offset += $p.Bytes.Length - } - foreach ($p in $payloads) { $bw.Write($p.Bytes) } - } finally { $bw.Dispose(); $fs.Dispose() } -} - -$icoPngPath = Join-Path $AssetsDir 'icon.png' -$trayPath = Join-Path $AssetsDir 'tray.png' -$setupIco = Join-Path $Stage 'setup-icon.ico' -$appIco = Join-Path $AssetsDir 'icon.ico' - -$big = New-ScaledBitmap $brandBitmap 256 -$big.Save($icoPngPath, [System.Drawing.Imaging.ImageFormat]::Png) -$big.Dispose() -# Tray sits in the notification area at 16px (Windows picks 20/24 on high DPI, hence 32 as source). -(New-ScaledBitmap $brandBitmap 32).Save($trayPath, [System.Drawing.Imaging.ImageFormat]::Png) -Write-MultiSizeIco -source $brandBitmap -sizes @(16, 32, 48, 256) -path $appIco -$brandBitmap.Dispose() -Copy-Item -LiteralPath $appIco -Destination $setupIco -Force -Write-Host (" icon.ico {0:N0} KB (16/32/48/256), tray.png, icon.png" -f ((Get-Item $appIco).Length / 1KB)) +# SOURCES are versioned, which is why a clean clone can always rebuild them. The generator also +# emits assets/skins/.* so the shell can recolor its window + tray icon with the SPA skin. +Write-Step 'Generating application icons from the brand assets' +& (Join-Path $RepoRoot 'scripts\generate-desktop-icons.ps1') -SetupIconPath (Join-Path $Stage 'setup-icon.ico') # --- 1. API (self-contained) ----------------------------------------------------------------- Write-Step 'Publishing API (self-contained win-x64)' diff --git a/deploy/desktop/README.md b/deploy/desktop/README.md index 334b6562..83ff5012 100644 --- a/deploy/desktop/README.md +++ b/deploy/desktop/README.md @@ -101,8 +101,8 @@ and a PostgreSQL 16 binaries folder (the `pgsql` directory from the EDB zip dist # -> out\NodePilot-Desktop-Setup-1.0.0.exe (sign with your Authenticode cert before distribution) ``` -The build generates the icons from the tracked brand asset `src/nodepilot-ui/public/appicon.png` -(multi-resolution `.ico`: 16/32/48/256), publishes the API self-contained (`-r win-x64 +The build generates the icons via `scripts/generate-desktop-icons.ps1` (see **Icons** below), +publishes the API self-contained (`-r win-x64 --self-contained true`, no single-file — the PowerShell SDK is folder-deployed), builds the SPA into `app\wwwroot`, packages the Electron shell with Electron Forge, stages the Postgres server runtime + scripts, and compiles the installer. @@ -122,6 +122,32 @@ Two build steps are load-bearing and easy to break by accident: is not optional — `initdb` fails without `postgres.bki` and the timezone data. `-PgBinariesPath` may therefore point at a full EDB folder or an already-trimmed one. +### Icons + +`scripts/generate-desktop-icons.ps1` renders `src/nodepilot-desktop/assets/` from the SPA's tracked +brand assets (`src/nodepilot-ui/public/appicon-.png`). The output is gitignored; the sources +are versioned, so a clean clone can always rebuild it. + +| Output | Used for | +|---|---| +| `icon.ico` (16/32/48/256) | exe, installer, Start-Menu entry, Explorer | +| `icon.png` / `tray.png` | every window + the tray until the SPA reports its skin | +| `skins\.png` / `-tray.png` | window + tray icon per SPA color skin | + +The static default is **blue** — rendered from `appicon-dark.png`, not from the untinted orange +source art `appicon.png` (`-DefaultSkin` picks a different one). At runtime the shell follows the +skin: the SPA rewrites `` to `/appicon-.png` on every skin switch, Chromium +reports that as `page-favicon-updated`, and `src/skins.ts` maps it back onto `skins\.*`. That +keeps the production SPA window preload-less and IPC-free — the shell reads a one-way signal the +renderer already broadcasts. The per-skin set is discovered from the `appicon-*.png` files, so a new +UI skin needs no change here. + +The `.exe`/installer/Start-Menu icon cannot follow a skin — Windows resolves those from the file +itself, which is why the shipped default matters. + +Running the Electron shell straight from source (`npm start`, see below) starts with an empty +`assets/`: run `npm run icons` in `src/nodepilot-desktop` once to populate it. + ## Install / update / uninstall - **Install:** run the `.exe` as a local administrator (UAC). It lays down files, runs @@ -141,6 +167,7 @@ Two build steps are load-bearing and easy to break by accident: | File | Role | |---|---| | `Build-DesktopInstaller.ps1` | Build orchestrator (icons + publish + SPA + Modules + Electron + PG subset + ISCC). | +| `../../scripts/generate-desktop-icons.ps1` | Icon set from the SPA brand assets (default + per-skin); also runnable standalone. | | `Sync-DesktopApp.ps1` | Dev loop: pushes local changes into an installed app in ~1 min (see below). | | `NodePilot.iss` | Inno Setup installer definition. | | `Provision-LocalDb.ps1` | First-run/repeatable runtime provisioner (DB, services, cert, config, handoff). | @@ -156,6 +183,7 @@ app is just files plus two services, so day-to-day changes have much shorter loo | Changing | Fastest loop | Time | |---|---|---| | Electron shell | `cd src/nodepilot-desktop; npm start` — runs **from source** against the installed backend (it reads `%ProgramData%\NodePilot\desktop.json`), no packaging at all | seconds | +| Electron shell, icons | `npm run icons` in the same folder — regenerates `assets/` (empty in a fresh clone) | seconds | | Backend / SPA, normal work | ordinary dev mode (backend on 5000, Vite on 5173 with HMR) | seconds | | Backend / SPA, **as packaged** | `Sync-DesktopApp.ps1 -Component api\|spa\|all` (elevated) — incremental publish/build, robocopy into the installation, service restart + health poll | ~1 min | | Distribution | `Build-DesktopInstaller.ps1` | ~10–15 min | @@ -164,17 +192,22 @@ Use the sync script when the *packaging* matters — service identity is LocalSy bundled Postgres, TLS is the pinned loopback cert — none of which dev mode reproduces. It never mirrors over `app\Modules` or `app\wwwroot`, so the PowerShell modules and SPA stay intact. +Quit the installed shell first (tray → *Quit Electron*) before `npm start`: both resolve to the same +`productName`, so the single-instance lock makes the second one focus the first and exit. Shell +changes reach the *installed* app only through a new installer — `app.asar` is not patchable. + ## Known gaps (deliberately not covered in v1) Honest inventory so nobody assumes more coverage than exists: - **The Electron module's pure logic is unit-tested; its Electron-runtime behaviour is not.** `npm run test:run` in `src/nodepilot-desktop` (vitest, node environment) covers `config.ts` - (desktop.json handoff validation — origin, fingerprint, serviceName injection barrier) and + (desktop.json handoff validation — origin, fingerprint, serviceName injection barrier), `security.ts` (certificate-pin match/mismatch/parse-failure, non-loopback rejection, permission - and download blocking, navigation containment). What still needs a real Electron process — the - setup-token IPC guard, the elevated `restartBackend` path, window lifecycle — is verified only by - hand. The backend half of the feature *is* unit-tested (`DeploymentModeTests`, + and download blocking, navigation containment) and `skins.ts` (favicon → skin-icon resolution, + including the path-charset guard on the renderer-supplied id). What still needs a real Electron + process — the setup-token IPC guard, the elevated `restartBackend` path, window lifecycle, and + whether Chromium actually reports the SPA's favicon swap — is verified only by hand. The backend half of the feature *is* unit-tested (`DeploymentModeTests`, `DatabaseTlsBootValidatorTests`, `DatabaseReadinessGateTests`, `KestrelHttpsConfiguratorTests`). - **No CI coverage for `deploy/desktop/*`.** The `desktop` CI job runs `npm audit`, typecheck and vitest for `src/nodepilot-desktop`, and the nightly script adds a `desktop-vitest` suite; there is diff --git a/docs/ai-features.md b/docs/ai-features.md index 1bdf7ffc..49e401a1 100644 --- a/docs/ai-features.md +++ b/docs/ai-features.md @@ -362,6 +362,13 @@ DB-Tools nutzen Strict Function Schemas; inkompatible lokale Endpoints erhalten Best-Effort-Retry. SQL-Text wird nicht auditiert, stattdessen nur Anzahl und SHA-256-Kurzfingerprints. Text2SQL ist nur als Capability sichtbar, wenn das aktive Profil `EnableToolCalling=true` hat. +**Einstiegsvorschläge folgen den Capabilities.** Der Empty-State hält zwei Prompt-Sets in i18n +(`ai:knowledge.examples` + `ai:knowledge.examplesLite`, je 8 Strings) und wählt anhand von `caps.db`: +mit DB-Quelle Betriebsauswertungen (letzte Fehlläufe, hängende Runs, unerreichbare Maschinen, +Audit-Trail), sonst Doku- und Zeitplan-Fragen. Grund: die Alltagsfragen brauchen text2sql, das per +Default aus **und** Admin/Operator-only ist — einem Viewer würden sie nur „Quelle nicht verfügbar" +liefern. Das Grid rendert erst nach dem Auflösen der Capabilities, sonst blitzt das Lite-Set auf. + --- ## Bewusst auf v2 verschoben diff --git a/docs/claude-reference.md b/docs/claude-reference.md index 6688ba62..602ee6c1 100644 --- a/docs/claude-reference.md +++ b/docs/claude-reference.md @@ -50,6 +50,8 @@ in den Kontext-Window geladen werden müssen, aber bei Bedarf nachschlagbar sind - **Maschine gesetzt** (non-loopback bzw. + Credential) → NodePilot baut die WinRM-Session über die gepoolte `WinRmSessionFactory` (`ExecuteRemoteAsync`). Das Script läuft auf dem Ziel, **kein** Session-Management im Script nötig. - **Keine Maschine** (bzw. loopback ohne Credential) → der Node läuft **engine-local im API-Host** (`ExecuteLocalAsync`, Runspace-Pool bzw. isolierter Prozess). Von dort kann das Script die Remote-Verbindung **selbst** herstellen — SCOrch-Stil: `Invoke-Command -ComputerName SRV01 -Credential $c { … }` / `New-PSSession`. Sinnvoll für dynamische Ziellisten, Fan-out auf N Maschinen in **einem** Node oder Jump-Host-Ketten. +**Engine-local = PowerShell-SDK, nicht volles pwsh:** Der In-Process-Pool shippt nur die acht Core-Module plus das gebündelte `Microsoft.PowerShell.Archive` (eager importiert, `PSModules\` im Output — Grund: WinPSCompat-Session-Leak 2026-07-30, siehe `docs/performance-improvements.md`). **Implizite Windows-PowerShell-Kompatibilität ist deaktiviert** (`powershell.config.json` mit `DisableImplicitWinCompat` neben der SDK-DLL, platziert via `Directory.Build.targets`): Ein Desktop-only-Modul (z. B. die System32-Kopien ohne Core-Flag) schlägt engine-local **laut** fehl statt still einen `powershell.exe -s`-Kindprozess pro Pool-Runspace zu akkumulieren. CDXML-Module (ScheduledTasks, NetTCPIP, Defender, …) laden weiterhin nativ; bewusstes `Import-Module -UseWindowsPowerShell` bleibt möglich (die so erzeugte Session gehört dann dem Script — inkl. Aufräumen). `pwsh`/`powershell.exe`-Prozess-Engines und der WinRM-Pfad sind nicht betroffen (eigenes `$PSHOME` bzw. Remote-Seite). + **Trade-offs beim Self-Managen** (bewusst außerhalb des managed WinRM-Pfads): 1. Läuft auf dem **API-Host**, nicht auf dem Ziel — der Host braucht Netz-/WinRM-Client-Zugriff (TrustedHosts/Kerberos/SSL) selbst. @@ -382,8 +384,21 @@ Alerting-Webhooks). Merksatz: *ausgehend alles, eingehend nichts.* - **`$PSHOME\Modules` muss mitgeliefert werden**, sonst schlägt jedes `runScript` fehl (Modul-Staging im Build) - Deploy-Skripte **ASCII-only** halten (UTF-8-no-BOM wird als ANSI gelesen) +**Icons (skin-folgend):** [scripts/generate-desktop-icons.ps1](scripts/generate-desktop-icons.ps1) +rendert `src/nodepilot-desktop/assets/` aus den Brand-Assets der SPA — Default-Set (`icon.ico` +16/32/48/256, `icon.png`, `tray.png`) **blau** aus `appicon-dark.png` plus `skins/.png` + +`-tray.png` je Skin. Zur Laufzeit folgt die Shell dem Skin: die SPA schreibt bei jedem Wechsel +`/appicon-.png` in ``, Chromium meldet das als `page-favicon-updated`, und +[skins.ts](src/nodepilot-desktop/src/skins.ts) mappt es zurück auf `skins/.*` (Fenster- + +Tray-Icon). Bewusst **kein** Preload/IPC am Produktions-SPA-Fenster — die Shell liest ein Signal, +das der Renderer ohnehin sendet. Die Skin-Liste ist **nicht** gespiegelt: der Generator leitet sie +aus den vorhandenen `appicon-*.png` ab, die Shell aus den erzeugten Dateien (unbekannter Skin → +aktuelles Icon bleibt). Nur exe/Installer/Startmenü-Icon bleibt fix — Windows löst die aus der Datei +auf. Output ist gitignored, die Quellen sind versioniert. + **Dev-Loop:** `Sync-DesktopApp.ps1` (~1 Min) statt Installer-Rebuild; Electron-Shell via `npm start` -direkt aus dem Quellcode gegen die installierte Backend-Instanz. +direkt aus dem Quellcode gegen die installierte Backend-Instanz (davor einmal `npm run icons`, +sonst ist `assets/` leer). --- @@ -499,7 +514,15 @@ Dashboard + Workflow-Listen lesen ein **vorberechnetes** `WorkflowStats`-Aggrega **Warum der Sample-Cap:** Zähler und Zeitstempel aggregiert die DB serverseitig, Perzentile lassen sich aber nicht provider-agnostisch in LINQ ausdrücken. Die Dauer-Samples werden deshalb pro Workflow über den deckenden Index `(WorkflowId, StartedAt DESC)` geholt und hart gedeckelt — vorher materialisierte ein Pass **jeden** Erfolgslauf des Fensters im Speicher, alle 5 Minuten, für drei Kennzahlen. -**Zeitfenster + Truncation.** `GET /api/operations/graph?windowMinutes=` ist serverseitig auf `{20, 60, 240}` geklemmt (alles andere → 20) und steuert **nur** die beendeten Läufe. `running[]` ist bewusst nie gefenstert — ein seit sechs Stunden laufender Job muss in jedem Fenster sichtbar bleiben, das ist ja der Stuck-Fall. Ein Cap (`RecentCap = 500`) gilt für alle Fenster; das Fenster ohne Cap-Anhebung zu verbreitern hätte stille **Löcher** in die Timeline gerissen (die neuesten N überleben, ältere verschwinden mitten im Fenster), was sich als „da lief nichts" liest. Deshalb drei Meta-Felder mit drei verschiedenen Bedeutungen: `RecentSinceUtc` = *angeforderter* linker Rand, `OldestReturnedCompletedAt` = ältester *tatsächlich gelieferter* Abschluss, `RecentTruncated` = ob der Cap gegriffen hat. Das „keine Historie"-Band wird ab `OldestReturnedCompletedAt` gezeichnet, **nicht** ab `RecentSinceUtc` — bei Truncation markierte letzteres genau die Strecke, die *nicht* verloren ging. +**Zeitfenster, Balken-Cap und Dichte.** `GET /api/operations/graph?windowMinutes=` ist serverseitig auf `{20, 60, 240}` geklemmt (alles andere → 20) und steuert **nur** die beendeten Läufe. `running[]` ist bewusst nie gefenstert — ein seit sechs Stunden laufender Job muss in jedem Fenster sichtbar bleiben, das ist ja der Stuck-Fall. + +`RecentCap = 1000` gilt für **jedes** Fenster, weil er ein **Render-Budget** ist und kein Fenster-Budget: er begrenzt, wie viele Balken die Konsole bei jedem Uhr-Tick neu positioniert und bei jedem 5-s-Poll neu empfängt — beides hängt nicht daran, wie weit zurück jemand geschaut hat. Auf einer ausgelasteten Anlage (gemessen: 982 beendete Läufe/h über 24 Workflows) deckt der Cap ~30 min ab; alles darüber hinaus mit Rohzeilen zu füllen hieße ~3.900 Zeilen alle 5 s — nicht tragbar. + +Die Abdeckung des Fensters übernimmt deshalb **`density[]`**: pro Workflow gebucketete Zähler (`total`/`failed`/`cancelled`) über das **ganze** Fenster, berechnet **nur wenn** der Cap gegriffen hat (ruhige Anlage → zweite Query und Payload entfallen komplett). Feste Bucket-**Anzahl** statt fester Breite (`DensityBucketTarget = 48`, also 25 s bei 20 min, 5 min bei 4 h) — dadurch kostet ein breiteres Fenster nichts extra, egal wie viele Läufe dahinterstehen. Aggregiert wird **in-memory** über einen gedeckelten Scan (`DensityScanCap = 20.000`), bewusst nicht in SQL: portables Datums-Bucketing über Postgres, SQL Server **und** das SQLite-Test-Backend ist ein Übersetzungs-Minenfeld, ein schmaler Range-Read dagegen billig. + +Fünf Meta-Felder mit fünf verschiedenen Bedeutungen: `RecentSinceUtc` = *angeforderter* linker Rand (und Anker von Bucket 0), `OldestReturnedCompletedAt` = ältester *tatsächlich gelieferter* Abschluss (die Naht zwischen Balken und Dichte), `RecentTruncated` = ob der Cap gegriffen hat, `DensityBucketSeconds` = Bucket-Breite, `DensityCapped` = ob schon das Aggregat gedeckelt wurde (die Zähler sind dann eine Untergrenze, keine Summe). + +Die Buckets decken absichtlich das ganze Fenster ab und nicht nur die fehlende Strecke: die Naht fällt mitten in einen Bucket (ein dort abgeschnittener Bucket zählte sich selbst zu klein), und so ist die Bucket-Summe die ehrliche Fenster-Gesamtzahl für die Hinweiszeile. Das **Clipping** an der Naht ist eine reine Render-Entscheidung in `buildDensityCells` — rechts davon ist jeder Lauf ohnehin ein Balken. Das „keine Historie"-Band entfällt, sobald Dichte da ist: es behauptet „für diese Strecke kam nichts zurück", und die Dichte ist genau die Widerlegung. Ein Workflow **nur** mit Dichte (jeder seiner Läufe fiel hinter den Cap) bekommt in `assignLanes` trotzdem eine Lane — sonst hätte seine Historie nichts zum Zeichnen und der Workflow läse sich als untätig. **Freeze ist ein Darstellungs-Freeze.** Eingefroren werden nur die Render-Inputs (Snapshot, `locallySettled`, Uhr). Der SignalR-Feed bleibt verbunden, `seedRunning` reconciled weiter, und Hintergrund-Invalidierungen dürfen weiterhin Requests auslösen. `useOperationsFeed()` darf **niemals** bedingt aufgerufen werden: ohne den Feed schriebe `applyStatus` keine Tombstones mehr, und ein Refetch nach dem Auftauen könnte Läufe wiederbeleben, die währenddessen terminiert sind. Der Query-Key ist `['operations-graph', windowMinutes]`; beim Fensterwechsel verliert die alte Query ihren Observer und pollt von selbst nicht weiter. diff --git a/docs/performance-improvements.md b/docs/performance-improvements.md index 7f851b7a..1e0febbb 100644 --- a/docs/performance-improvements.md +++ b/docs/performance-improvements.md @@ -576,3 +576,58 @@ Die 132×-Verlangsamung von `collect-host` ist die **dominante Engine-Decke** f ### Sperrvermerk Die vier Hebel dieser Session (`MaxConcurrentSteps` 1500, `ChildSemaphore` 600, DB-Pool 1500, `VACUUM ANALYZE`) sind gemessen widerlegt und stehen als solche im Anhang von [`roadmap.md`](roadmap.md). Diese Sektion ist die Beweislage — wer sie erneut vorschlägt, misst zuerst mit dem hier dokumentierten 500-Parallel-Test. + +--- + +## WinPSCompat-Session-Leak im In-Process-Pool (Session 2026-07-30) + +Die Dev-API wuchs unter der 2-min-Dauertest-Batterie auf **10,05 GB committed / gen2 7,49 GB +(~93 % live) / 472 Threads** in 9,7 h (~1 MB pro Execution, Threads +45/h). Ursache war kein +Engine-Tuning-Hebel, sondern ein Leck im Modul-Laden des In-Process-Pools. + +### Ursache-Kette + +1. Dauertest-Workflows targeten die localhost-Maschine ohne Credential → **Localhost-Bypass** + führt die „Remote"-Steps engine-local im PS7-**SDK**-RunspacePool aus (nicht WinRM). +2. `zipOperation` ruft `Compress-Archive` — **`Microsoft.PowerShell.Archive` ist im + PowerShell-SDK-NuGet nicht enthalten** (das SDK shippt nur die acht Core-Module). +3. Der Modul-Auto-Loader fand die Windows-PowerShell-5.1-Kopie unter System32 (Edition + `Desktop`) und lud sie über die **implizite WinCompat-Schiene**: ein + `powershell.exe -Version 5.1 -s`-Kindprozess (~148 MB) pro Pool-Runspace, als + `WinPSCompatSession` im `RunspaceRepository` **des jeweiligen Runspace** registriert. +4. Pool-Runspaces (256/768) sterben nie → jeder Zyklus auf einem frischen Runspace erzeugte eine + neue Session; **keine wurde je geschlossen.** Pro Session: 1 Transport-Thread + (`ProcessMessageProc`), Proxy-Modul + importierte Format-Daten im gen2, plus der Kindprozess. + +Beweise: Kindprozesse mit Parent=API im exakten 2-min-Takt; `gcroot` (3/3 identisch): +`LocalRunspace → RunspaceRepository → PSSession("WinPSCompatSession") → RemoteRunspace → +OutOfProcessClientSessionTransportManager`; `PSModuleInfo._name = "Microsoft.PowerShell.Archive"`. +Gegenprobe: Desktop-Install (eigene DB ohne Dauertests, Pool 8/64) nach 24 h flach bei 216 MB. + +### Fix (zweiteilig) + +1. **`Microsoft.PowerShell.Archive` 1.2.5 gebündelt** (`src/NodePilot.Engine/PowerShell/Modules/`, + MIT, unmodifiziert — dieselbe Version, die volles pwsh 7 bundlet) → Output `PSModules\`, + eager in jede Pool-ISS importiert (`RunspaceExecutionEngine`). `Compress-/Expand-Archive` + laufen nativ in-process, der Auto-Loader sucht gar nicht erst. +2. **Implizite WinCompat deaktiviert** für das In-Proc-SDK: `powershell.config.json` + (`DisableImplicitWinCompat: true`) wird via `Directory.Build.targets` neben die + `System.Management.Automation.dll` gelegt (Build- und Publish-Layout). Künftige Desktop-only- + Cmdlets scheitern **laut** („…disabled in the settings file") statt still zu leaken; + explizites `Import-Module -UseWindowsPowerShell` bleibt möglich. `powershell.exe`/`pwsh`- + Prozess-Engines haben eigenes `$PSHOME` und sind nicht betroffen. + +Wächter-Tests: `RunspaceEngineWinCompatTests` — (a) Archive-Roundtrip muss aus `PSModules\` +kommen, (b) expliziter Import der Desktop-only-System32-Kopie muss mit der Settings-File-Meldung +scheitern und darf keine Session hinterlassen. Entfernt jemand die Config-Platzierung, lädt +WinCompat wieder still → Test (b) wird rot. + +### Sperrvermerk-Ergänzung + +Auf dem Weg zur Ursache wurde **Shared `FormatTable` über Pool-Runspaces** implementiert, gemessen +und **verworfen**: nur ~0,4–0,7 MB/Runspace Ersparnis (Runspaces kosten 1,2–1,4 MB und werden bei +Dispose frei — der Pool war nie der Halter), und `Update-FormatData`/`FormatsToProcess` brechen an +einer Shared Table terminierend (`CannotUpdateSharedFormatTable`) → über `ps.HadErrors` ein roter +Step. Ebenfalls aktenkundig: `dotnet-gcdump` bricht still am 10-Mio.-Objekt-Cap ab — das +Typ-Histogramm eines übergroßen Heaps beschreibt dann nur die Stichprobe. Kausalität immer über +vollen `dotnet-dump` + `gcroot` auf mehrere Instanzen belegen. diff --git a/scripts/generate-desktop-icons.ps1 b/scripts/generate-desktop-icons.ps1 new file mode 100644 index 00000000..09a46a8a --- /dev/null +++ b/scripts/generate-desktop-icons.ps1 @@ -0,0 +1,159 @@ +#requires -Version 5.1 +<# +.SYNOPSIS + Generates the Electron desktop shell's icon set from the SPA's tracked brand assets. + +.DESCRIPTION + Writes into src\nodepilot-desktop\assets (gitignored - the SOURCES are versioned, so a clean + clone can always rebuild them): + + icon.ico multi-resolution 16/32/48/256 - exe, installer, Start-Menu, Explorer + icon.png 256px window icon shown until the SPA reports its skin + tray.png 32px notification-area icon + skins\.png 256px window icon per SPA color skin + skins\-tray.png 32px tray icon per SPA color skin + + The static default set is deliberately BLUE: it is rendered from appicon-.png, + NOT from the untinted orange source art appicon.png. At runtime the shell swaps to + skins\.* the moment the SPA reports its favicon (see src\nodepilot-desktop\src\skins.ts), + so window and tray icon follow the skin the user picked. + + The per-skin set is discovered from public\appicon-*.png instead of a hardcoded list, so a new + UI skin needs no change here - regenerate its brand asset with scripts\generate-logo-skins.py + and rerun this script. + + Called by deploy\desktop\Build-DesktopInstaller.ps1. Run it standalone before + `npm start` in src\nodepilot-desktop to get real icons in the from-source dev loop. + +.EXAMPLE + ./scripts/generate-desktop-icons.ps1 +#> +[CmdletBinding()] +param( + # Skin whose brand asset becomes the static default. Must exist as public\appicon-.png. + [ValidatePattern('^[a-z][a-z0-9-]{0,31}$')] + [string] $DefaultSkin = 'dark', + # Optional extra copy of the generated .ico (Inno Setup's SetupIconFile). + [string] $SetupIconPath +) + +Set-StrictMode -Version 3.0 +$ErrorActionPreference = 'Stop' + +$RepoRoot = (Resolve-Path (Join-Path $PSScriptRoot '..')).Path +$PublicDir = Join-Path $RepoRoot 'src\nodepilot-ui\public' +$AssetsDir = Join-Path $RepoRoot 'src\nodepilot-desktop\assets' +$SkinsDir = Join-Path $AssetsDir 'skins' + +# Skin ids reach a path join in the Electron main process, so they are held to the same strict +# charset on both sides (see SKIN_ID in src\nodepilot-desktop\src\skins.ts). +$SKIN_ID = '^[a-z][a-z0-9-]{0,31}$' + +Add-Type -AssemblyName System.Drawing + +function New-ScaledBitmap([System.Drawing.Image] $source, [int] $size) { + # 32bpp ARGB + HighQualityBicubic keeps the logo's transparency and edges intact when the + # source is reduced to icon sizes. + $bmp = New-Object System.Drawing.Bitmap($size, $size, [System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + $g = [System.Drawing.Graphics]::FromImage($bmp) + $g.InterpolationMode = [System.Drawing.Drawing2D.InterpolationMode]::HighQualityBicubic + $g.PixelOffsetMode = [System.Drawing.Drawing2D.PixelOffsetMode]::HighQuality + $g.SmoothingMode = [System.Drawing.Drawing2D.SmoothingMode]::HighQuality + $g.CompositingQuality = [System.Drawing.Drawing2D.CompositingQuality]::HighQuality + $g.Clear([System.Drawing.Color]::Transparent) + $g.DrawImage($source, (New-Object System.Drawing.Rectangle(0, 0, $size, $size))) + $g.Dispose() + return $bmp +} + +function Save-ScaledPng([System.Drawing.Image] $source, [int] $size, [string] $path) { + $bmp = New-ScaledBitmap $source $size + try { $bmp.Save($path, [System.Drawing.Imaging.ImageFormat]::Png) } finally { $bmp.Dispose() } +} + +# Multi-resolution ICO, written by hand: System.Drawing's Icon.Save(GetHicon()) emits a single +# resolution, which Windows then rescales badly in Explorer and the taskbar. The ICO container is +# a 6-byte header + one 16-byte directory entry per image + the PNG payloads (PNG-compressed +# entries are valid since Vista and keep the file small). +function Write-MultiSizeIco([System.Drawing.Image] $source, [int[]] $sizes, [string] $path) { + $payloads = foreach ($s in $sizes) { + $bmp = New-ScaledBitmap $source $s + $ms = New-Object System.IO.MemoryStream + $bmp.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png) + $bmp.Dispose() + [pscustomobject]@{ Size = $s; Bytes = $ms.ToArray() } + $ms.Dispose() + } + + $fs = [System.IO.File]::Create($path) + $bw = New-Object System.IO.BinaryWriter($fs) + try { + $bw.Write([uint16]0) # reserved + $bw.Write([uint16]1) # type: icon + $bw.Write([uint16]$payloads.Count) + $offset = 6 + (16 * $payloads.Count) # header + directory + foreach ($p in $payloads) { + # 256 is encoded as 0 in the single-byte width/height fields. + $dim = if ($p.Size -ge 256) { 0 } else { $p.Size } + $bw.Write([byte]$dim); $bw.Write([byte]$dim) + $bw.Write([byte]0) # palette colours (0 = truecolour) + $bw.Write([byte]0) # reserved + $bw.Write([uint16]1) # colour planes + $bw.Write([uint16]32) # bits per pixel + $bw.Write([uint32]$p.Bytes.Length) + $bw.Write([uint32]$offset) + $offset += $p.Bytes.Length + } + foreach ($p in $payloads) { $bw.Write($p.Bytes) } + } finally { $bw.Dispose(); $fs.Dispose() } +} + +if (-not (Test-Path -LiteralPath $PublicDir)) { throw "SPA public folder not found: $PublicDir" } +New-Item -ItemType Directory -Force -Path $AssetsDir, $SkinsDir | Out-Null + +# --- per-skin set ---------------------------------------------------------------------------- +# Stale variants of a removed skin would otherwise linger and keep being applied. +Get-ChildItem -LiteralPath $SkinsDir -Filter '*.png' -File -ErrorAction SilentlyContinue | + Remove-Item -Force + +$skinSources = @(Get-ChildItem -LiteralPath $PublicDir -Filter 'appicon-*.png' -File | Sort-Object Name) +if ($skinSources.Count -eq 0) { + throw "No per-skin brand assets in $PublicDir (expected appicon-.png). Run scripts\generate-logo-skins.py first." +} + +$skins = @() +foreach ($file in $skinSources) { + $skin = $file.BaseName.Substring('appicon-'.Length) + if ($skin -notmatch $SKIN_ID) { + throw "Brand asset $($file.Name) yields the unsupported skin id '$skin' (allowed: $SKIN_ID)." + } + $img = [System.Drawing.Image]::FromFile($file.FullName) + try { + Save-ScaledPng $img 256 (Join-Path $SkinsDir "$skin.png") + # Tray sits in the notification area at 16px (Windows picks 20/24 on high DPI, hence 32). + Save-ScaledPng $img 32 (Join-Path $SkinsDir "$skin-tray.png") + } finally { $img.Dispose() } + $skins += $skin +} + +# --- static default set (blue) --------------------------------------------------------------- +$defaultSource = Join-Path $PublicDir "appicon-$DefaultSkin.png" +if (-not (Test-Path -LiteralPath $defaultSource)) { + throw "Default skin '$DefaultSkin' has no brand asset ($defaultSource). Available: $($skins -join ', ')." +} + +$appIco = Join-Path $AssetsDir 'icon.ico' +$img = [System.Drawing.Image]::FromFile($defaultSource) +try { + Save-ScaledPng $img 256 (Join-Path $AssetsDir 'icon.png') + Save-ScaledPng $img 32 (Join-Path $AssetsDir 'tray.png') + Write-MultiSizeIco -source $img -sizes @(16, 32, 48, 256) -path $appIco +} finally { $img.Dispose() } + +if ($SetupIconPath) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $SetupIconPath) | Out-Null + Copy-Item -LiteralPath $appIco -Destination $SetupIconPath -Force +} + +Write-Host (" default '{0}': icon.ico {1:N0} KB (16/32/48/256) + icon.png + tray.png" -f $DefaultSkin, ((Get-Item $appIco).Length / 1KB)) +Write-Host (" skins\: {0} variants ({1})" -f $skins.Count, ($skins -join ', ')) diff --git a/scripts/generate-logo-skins.py b/scripts/generate-logo-skins.py index 39cc2672..55b3ac9d 100644 --- a/scripts/generate-logo-skins.py +++ b/scripts/generate-logo-skins.py @@ -29,7 +29,9 @@ MAX_SIZE = 384 # Uniform target hue (degrees) per skin id, matching that skin's --color-primary -# accent family. `dark` = 25 reproduces the previously-approved orange appicon.png. +# accent family. The orange of `appicon.png` is the untinted source art, not a skin — +# no entry here reproduces it. `dark` doubles as the Electron shell's default icon +# (see scripts/generate-desktop-icons.ps1), which is why it must stay a brand blue. SKIN_HUE = { "light": 210, # blue (#004ac6 / #2563eb) "dark": 216, # azure (#6da8ff / #2467d9) diff --git a/src/NodePilot.Api/Controllers/OperationsController.cs b/src/NodePilot.Api/Controllers/OperationsController.cs index e74da96f..60611a5f 100644 --- a/src/NodePilot.Api/Controllers/OperationsController.cs +++ b/src/NodePilot.Api/Controllers/OperationsController.cs @@ -46,12 +46,31 @@ private int OverdueSeconds() private static readonly int[] AllowedWindowMinutes = [20, 60, 240]; /// - /// Cap on returned settled runs. Raising the window without raising this would punch silent - /// HOLES into the timeline (the newest N survive, older ones vanish mid-window) — which reads - /// as "nothing ran" rather than "too much ran". Hence one cap for every window plus an - /// explicit truncation flag, never a silent trim. + /// Cap on returned settled runs. This is a RENDER budget, not a window budget — the same + /// number at every window, because what it bounds is how many bars the console re-positions + /// on every clock tick and re-receives on every 5 s poll, neither of which cares how far back + /// the caller looked. Coverage of the window is not this cap's job: whatever it cannot reach + /// comes back aggregated in , so widening the window can no longer + /// punch a hole that reads as "nothing ran". /// - private const int RecentCap = 500; + private const int RecentCap = 1000; + + /// + /// Bucket count the density aggregate aims for at any window — the reason widening the window + /// costs nothing extra. At 20 min a bucket is 25 s, at 4 h it is 5 min; either way the console + /// receives at most lanes × 48 cells no matter how many runs are behind them. + /// + private const int DensityBucketTarget = 48; + + /// + /// Row ceiling for the density scan. The aggregate is computed in memory rather than in SQL + /// deliberately: portable date-part bucketing across Postgres, SQL Server AND the SQLite test + /// backend is a translation minefield, and 20 000 narrow rows is a cheap indexed range read. + /// At the 4 h window this is ~83 finished runs per minute sustained — well past the busiest + /// real load — and if it is ever hit, Meta.DensityCapped says so instead of quietly + /// under-counting. + /// + private const int DensityScanCap = 20_000; /// /// How many running executions get step-activity enrichment per snapshot. The oldest win — @@ -71,7 +90,7 @@ public async Task> GetGraph( { if (!AllowedWindowMinutes.Contains(windowMinutes)) windowMinutes = 20; var recentSince = DateTime.UtcNow.AddMinutes(-windowMinutes); - var emptyMeta = new OpsSnapshotMeta(OverdueSeconds(), windowMinutes, recentSince, null, false); + var emptyMeta = new OpsSnapshotMeta(OverdueSeconds(), windowMinutes, recentSince, null, false, 0, false); // RBAC: resolve the accessible-folder set once and scope every query to it. Global Admin // is unrestricted and skips the filter; a user with zero folder access gets an empty graph. @@ -81,7 +100,7 @@ public async Task> GetGraph( if (!accessible.IsUnrestricted) { if (accessible.FolderIds.Count == 0) - return Ok(new OperationsGraphDto([], [], [], [], emptyMeta)); + return Ok(new OperationsGraphDto([], [], [], [], [], emptyMeta)); workflowQuery = workflowQuery.Where(w => accessible.FolderIds.Contains(w.FolderId)); execQuery = execQuery.Where(e => accessible.FolderIds.Contains(e.Workflow.FolderId)); } @@ -183,11 +202,13 @@ public async Task> GetGraph( // fact we can report rather than a silent trim. `running` is deliberately NOT windowed — // a job that started six hours ago must stay on the timeline at every window setting; // that is precisely the stuck-run case the view exists for. - var recentFetched = await execQuery + var settledQuery = execQuery .Where(e => e.CompletedAt != null && e.CompletedAt >= recentSince && e.Status != ExecutionStatus.Running && e.Status != ExecutionStatus.Pending - && e.Status != ExecutionStatus.Paused) + && e.Status != ExecutionStatus.Paused); + + var recentFetched = await settledQuery .OrderByDescending(e => e.CompletedAt) .Take(RecentCap + 1) .Select(e => new { e.Id, e.WorkflowId, e.Status, e.StartedAt, e.CompletedAt, e.ParentExecutionId }) @@ -198,6 +219,25 @@ public async Task> GetGraph( // Ordered by CompletedAt desc, so the last row is the oldest one we actually returned. var oldestReturned = recentRows.Count > 0 ? recentRows[^1].CompletedAt : null; + // Density only when the bars could not cover the window. On a quiet system this whole + // block is skipped: no second query, no extra payload, timeline unchanged. + var density = Array.Empty() as IReadOnlyList; + var densityBucketSeconds = 0; + var densityCapped = false; + if (recentTruncated) + { + densityBucketSeconds = Math.Max(1, windowMinutes * 60 / DensityBucketTarget); + var scan = await settledQuery + .OrderByDescending(e => e.CompletedAt) + .Take(DensityScanCap + 1) + .Select(e => new { e.WorkflowId, e.CompletedAt, e.Status }) + .ToListAsync(ct); + densityCapped = scan.Count > DensityScanCap; + density = Bucketize( + scan.Take(DensityScanCap).Select(r => (r.WorkflowId, r.CompletedAt!.Value, r.Status)), + recentSince, densityBucketSeconds); + } + var wfIds = workflows.Select(w => w.Id).ToList(); var statsRows = await _db.WorkflowStats.AsNoTracking() .Where(s => wfIds.Contains(s.WorkflowId)) @@ -265,9 +305,50 @@ public async Task> GetGraph( .ToList(); var meta = new OpsSnapshotMeta( - OverdueSeconds(), windowMinutes, recentSince, oldestReturned, recentTruncated); + OverdueSeconds(), windowMinutes, recentSince, oldestReturned, recentTruncated, + densityBucketSeconds, densityCapped); + + return Ok(new OperationsGraphDto(nodes, edges, running, recent, density, meta)); + } + + /// + /// Groups settled runs into fixed-width time slices per workflow. + /// + /// Slices span the WHOLE window, not just the stretch the raw list missed. Two reasons: the + /// boundary between "is a bar" and "is a bucket" falls mid-slice, so a bucket cut at that + /// boundary would under-count itself; and covering everything makes the bucket sums the honest + /// total for the window, which is what the console puts in its notice line. The console draws + /// only the slices left of the seam — double-counting is a rendering concern, not a data one. + /// + /// + private static IReadOnlyList Bucketize( + IEnumerable<(Guid WorkflowId, DateTime CompletedAt, ExecutionStatus Status)> rows, + DateTime recentSince, + int bucketSeconds) + { + var counts = new Dictionary<(Guid WorkflowId, int Bucket), (int Total, int Failed, int Cancelled)>(); + foreach (var row in rows) + { + // Clamped at 0: a run whose CompletedAt sits a hair before recentSince (clock skew + // between the filter's timestamp and the row) belongs in the first slice, not in a + // negative one that would sort ahead of the window. + var offset = (int)Math.Max(0, (row.CompletedAt - recentSince).TotalSeconds / bucketSeconds); + var key = (row.WorkflowId, offset); + counts.TryGetValue(key, out var c); + counts[key] = ( + c.Total + 1, + c.Failed + (row.Status == ExecutionStatus.Failed ? 1 : 0), + c.Cancelled + (row.Status == ExecutionStatus.Cancelled ? 1 : 0)); + } - return Ok(new OperationsGraphDto(nodes, edges, running, recent, meta)); + return counts + .GroupBy(kv => kv.Key.WorkflowId) + .Select(g => new OpsDensityLane( + g.Key, + g.OrderBy(kv => kv.Key.Bucket) + .Select(kv => new OpsDensityBucket(kv.Key.Bucket, kv.Value.Total, kv.Value.Failed, kv.Value.Cancelled)) + .ToList())) + .ToList(); } /// diff --git a/src/NodePilot.Api/Dtos/OperationsGraphDto.cs b/src/NodePilot.Api/Dtos/OperationsGraphDto.cs index 90021f2c..401c971d 100644 --- a/src/NodePilot.Api/Dtos/OperationsGraphDto.cs +++ b/src/NodePilot.Api/Dtos/OperationsGraphDto.cs @@ -7,12 +7,21 @@ namespace NodePilot.Api.Dtos; /// never leaking its existence). Live status deltas arrive separately over the SignalR /// ops-feed group; this is the initial paint: nodes, static call topology, currently /// running executions and the recently finished ones for the live timeline. +/// +/// Density carries per-workflow run counts over the window and is populated ONLY when +/// Recent was capped. It is what makes the window selector mean something on a busy system: +/// individual bars cannot cover four hours (that is thousands of rows re-sent every poll and +/// re-positioned every clock tick), so the stretch the raw list does not reach comes back as an +/// aggregate instead of being left blank. Empty on a quiet system — nothing is computed and +/// nothing is shipped when the raw list already covers the window. +/// /// public record OperationsGraphDto( IReadOnlyList Nodes, IReadOnlyList Edges, IReadOnlyList Running, IReadOnlyList Recent, + IReadOnlyList Density, OpsSnapshotMeta Meta); /// Snapshot-wide settings the console needs to render consistently with the backend. @@ -35,15 +44,29 @@ public record OperationsGraphDto( /// band would cover exactly the stretch the truncation did NOT lose. /// /// -/// True when more settled runs existed in the window than the cap returns. Surfaced so an empty -/// stretch of track is never silently read as "nothing ran". +/// True when more settled runs existed in the window than the cap returns — i.e. exactly when +/// Density is populated. Surfaced so an empty stretch of track is never silently read as +/// "nothing ran". +/// +/// +/// Width of one , so the console can turn a bucket index back into +/// a time range: bucket i spans [RecentSinceUtc + i·s, RecentSinceUtc + (i+1)·s). +/// Zero when no density was computed. Sized to hit a fixed bucket count at every window, which is +/// what keeps the aggregate's cost independent of how busy the system is. +/// +/// +/// The density scan hit its own row cap, so the aggregate describes the newest N settled runs in +/// the window rather than all of them. Separate from on +/// purpose: that one says "not every run is a bar", this one says "not every run is even counted". /// public record OpsSnapshotMeta( int OverdueSeconds, int WindowMinutes, DateTime RecentSinceUtc, DateTime? OldestReturnedCompletedAt, - bool RecentTruncated); + bool RecentTruncated, + int DensityBucketSeconds, + bool DensityCapped); /// Live count of Running/Pending executions at snapshot time. /// Status of the most recent execution (from WorkflowStats), or null if never run. @@ -126,9 +149,10 @@ public record OpsRunningExecution( int? ActiveStepCount); /// -/// Terminal execution completed within the recent window (30 min, newest 200 win on very busy -/// systems — bars age out of the timeline before the cap matters). Slim on purpose: rich details -/// (error, triggeredBy, parent) come from GET /api/executions/{id} on drill-down. +/// Terminal execution completed within the requested window, newest first and capped — on a busy +/// system the runs past the cap come back aggregated in instead of +/// being dropped. Slim on purpose: rich details (error, triggeredBy, parent) come from +/// GET /api/executions/{id} on drill-down. /// /// Parent run for sub-workflow executions — see . public record OpsRecentExecution( @@ -139,3 +163,30 @@ public record OpsRecentExecution( DateTime CompletedAt, Guid? ParentExecutionId); +/// +/// Settled-run counts for one workflow, bucketed over the window. Nested under the workflow rather +/// than carrying a WorkflowId per bucket: at ~48 buckets × N workflows the repeated GUID +/// would be the bulk of the payload, and the console wants them grouped by lane anyway. +/// +/// The lane these counts belong to. +/// Only buckets with at least one run. Ascending by index; index 0 is the +/// oldest bucket, starting at Meta.RecentSinceUtc. +public record OpsDensityLane( + Guid WorkflowId, + IReadOnlyList Buckets); + +/// +/// One time slice of one workflow's settled runs. Outcome is split rather than reduced to a single +/// "worst status": a slice holding nineteen successes and one failure is neither green nor red, +/// and collapsing it either way is a lie the operator would act on. +/// +/// Slice offset from Meta.RecentSinceUtc, in Meta.DensityBucketSeconds steps. +/// All settled runs in the slice, whatever their status. +/// Runs that ended Failed. +/// Runs that ended Cancelled. +public record OpsDensityBucket( + int BucketIndex, + int Total, + int Failed, + int Cancelled); + diff --git a/src/NodePilot.Cli/Api/Dtos/OperationsDtos.cs b/src/NodePilot.Cli/Api/Dtos/OperationsDtos.cs index ff4d2518..462998cb 100644 --- a/src/NodePilot.Cli/Api/Dtos/OperationsDtos.cs +++ b/src/NodePilot.Cli/Api/Dtos/OperationsDtos.cs @@ -2,11 +2,14 @@ namespace NodePilot.Cli.Api.Dtos; // CLI-side mirror of NodePilot.Api.Dtos.OperationsGraphDto (no ProjectReference per convention). +/// Bucketed run counts for the stretch +/// could not reach; empty whenever the raw list already covers the window. public sealed record OperationsGraphResponse( IReadOnlyList Nodes, IReadOnlyList Edges, IReadOnlyList Running, IReadOnlyList Recent, + IReadOnlyList Density, OpsSnapshotMetaDto Meta); /// Long-running threshold (Alerting:LongRunningSeconds). @@ -14,12 +17,29 @@ public sealed record OperationsGraphResponse( /// Left edge the caller asked for. /// Oldest settled run actually returned; null when none. /// More settled runs existed in the window than the cap returns. +/// Width of one density bucket; 0 when no density was computed. +/// Density describes the newest N settled runs only, not all of them. public sealed record OpsSnapshotMetaDto( int OverdueSeconds, int WindowMinutes, DateTime RecentSinceUtc, DateTime? OldestReturnedCompletedAt, - bool RecentTruncated); + bool RecentTruncated, + int DensityBucketSeconds, + bool DensityCapped); + +/// The lane these counts belong to. +/// Only buckets with at least one run, ascending by index. +public sealed record OpsDensityLaneDto( + Guid WorkflowId, + IReadOnlyList Buckets); + +/// Offset from Meta.RecentSinceUtc in Meta.DensityBucketSeconds steps. +public sealed record OpsDensityBucketDto( + int BucketIndex, + int Total, + int Failed, + int Cancelled); /// Caller may cancel / retry / cancel-all on this workflow (folder Run right). /// Caller may disable / quarantine this workflow (folder Edit right). diff --git a/src/NodePilot.Cli/Commands/Operations/OperationsCommands.cs b/src/NodePilot.Cli/Commands/Operations/OperationsCommands.cs index 55ac13df..12dda146 100644 --- a/src/NodePilot.Cli/Commands/Operations/OperationsCommands.cs +++ b/src/NodePilot.Cli/Commands/Operations/OperationsCommands.cs @@ -38,7 +38,17 @@ private static void Render(IAnsiConsole console, OperationsGraphResponse graph) var runningTotal = graph.Nodes.Sum(n => n.RunningCount); console.MarkupLine($"[bold]Workflows:[/] {graph.Nodes.Count} [bold]Edges:[/] {graph.Edges.Count} [bold]Running:[/] {runningTotal} [bold]Recent:[/] {graph.Recent.Count} (last {graph.Meta.WindowMinutes} min)"); if (graph.Meta.RecentTruncated) - console.MarkupLine("[yellow]Note:[/] more finished runs exist in this window than were returned — older ones are omitted."); + { + // Not "omitted" — the runs past the cap come back as density. Report what they add up + // to, which is the number a `np operations graph` reader actually wants. + var counted = graph.Density.Sum(l => l.Buckets.Sum(b => b.Total)); + var failed = graph.Density.Sum(l => l.Buckets.Sum(b => b.Failed)); + var prefix = graph.Meta.DensityCapped ? "more than " : ""; + console.MarkupLine( + $"[yellow]Note:[/] {prefix}{counted} finished runs in this window ({failed} failed) — " + + $"only the newest {graph.Recent.Count} are listed individually, the rest are aggregated " + + $"into {graph.Meta.DensityBucketSeconds}s density buckets."); + } var nodes = new Table().Border(TableBorder.Rounded) .AddColumn("Workflow").AddColumn("Folder").AddColumn("Enabled").AddColumn("Running").AddColumn("Last"); diff --git a/src/NodePilot.Engine/NodePilot.Engine.csproj b/src/NodePilot.Engine/NodePilot.Engine.csproj index db455cae..b8022322 100644 --- a/src/NodePilot.Engine/NodePilot.Engine.csproj +++ b/src/NodePilot.Engine/NodePilot.Engine.csproj @@ -35,4 +35,18 @@ + + + + PSModules\Microsoft.PowerShell.Archive\%(RecursiveDir)%(Filename)%(Extension) + PreserveNewest + + + diff --git a/src/NodePilot.Engine/PowerShell/Modules/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psd1 b/src/NodePilot.Engine/PowerShell/Modules/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psd1 new file mode 100644 index 00000000..8a0ea92a --- /dev/null +++ b/src/NodePilot.Engine/PowerShell/Modules/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psd1 @@ -0,0 +1,208 @@ +@{ +GUID="eb74e8da-9ae2-482a-a648-e96550fb8733" +Author="Microsoft Corporation" +CompanyName="Microsoft Corporation" +Copyright="© Microsoft Corporation. All rights reserved." +Description='PowerShell module for working with ZIP archives.' +ModuleVersion="1.2.5" +PowerShellVersion="3.0" +FunctionsToExport = @('Compress-Archive', 'Expand-Archive') +DotNetFrameworkVersion = 4.5 +CmdletsToExport = @() +AliasesToExport = @() +NestedModules="Microsoft.PowerShell.Archive.psm1" +HelpInfoURI = 'https://go.microsoft.com/fwlink/?LinkId=2113631' +} + +# SIG # Begin signature block +# MIIjhgYJKoZIhvcNAQcCoIIjdzCCI3MCAQExDzANBglghkgBZQMEAgEFADB5Bgor +# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG +# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCAgseQYNytK1g1h +# 9FUbgEFrpyXsmaTUaHw3dw1AixKKnKCCDYEwggX/MIID56ADAgECAhMzAAABUZ6N +# j0Bxow5BAAAAAAFRMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD +# VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy +# b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p +# bmcgUENBIDIwMTEwHhcNMTkwNTAyMjEzNzQ2WhcNMjAwNTAyMjEzNzQ2WjB0MQsw +# CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u +# ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy +# b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +# AQCVWsaGaUcdNB7xVcNmdfZiVBhYFGcn8KMqxgNIvOZWNH9JYQLuhHhmJ5RWISy1 +# oey3zTuxqLbkHAdmbeU8NFMo49Pv71MgIS9IG/EtqwOH7upan+lIq6NOcw5fO6Os +# +12R0Q28MzGn+3y7F2mKDnopVu0sEufy453gxz16M8bAw4+QXuv7+fR9WzRJ2CpU +# 62wQKYiFQMfew6Vh5fuPoXloN3k6+Qlz7zgcT4YRmxzx7jMVpP/uvK6sZcBxQ3Wg +# B/WkyXHgxaY19IAzLq2QiPiX2YryiR5EsYBq35BP7U15DlZtpSs2wIYTkkDBxhPJ +# IDJgowZu5GyhHdqrst3OjkSRAgMBAAGjggF+MIIBejAfBgNVHSUEGDAWBgorBgEE +# AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQUV4Iarkq57esagu6FUBb270Zijc8w +# UAYDVR0RBEkwR6RFMEMxKTAnBgNVBAsTIE1pY3Jvc29mdCBPcGVyYXRpb25zIFB1 +# ZXJ0byBSaWNvMRYwFAYDVQQFEw0yMzAwMTIrNDU0MTM1MB8GA1UdIwQYMBaAFEhu +# ZOVQBdOCqhc3NyK1bajKdQKVMFQGA1UdHwRNMEswSaBHoEWGQ2h0dHA6Ly93d3cu +# bWljcm9zb2Z0LmNvbS9wa2lvcHMvY3JsL01pY0NvZFNpZ1BDQTIwMTFfMjAxMS0w +# Ny0wOC5jcmwwYQYIKwYBBQUHAQEEVTBTMFEGCCsGAQUFBzAChkVodHRwOi8vd3d3 +# Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NlcnRzL01pY0NvZFNpZ1BDQTIwMTFfMjAx +# MS0wNy0wOC5jcnQwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAgEAWg+A +# rS4Anq7KrogslIQnoMHSXUPr/RqOIhJX+32ObuY3MFvdlRElbSsSJxrRy/OCCZdS +# se+f2AqQ+F/2aYwBDmUQbeMB8n0pYLZnOPifqe78RBH2fVZsvXxyfizbHubWWoUf +# NW/FJlZlLXwJmF3BoL8E2p09K3hagwz/otcKtQ1+Q4+DaOYXWleqJrJUsnHs9UiL +# crVF0leL/Q1V5bshob2OTlZq0qzSdrMDLWdhyrUOxnZ+ojZ7UdTY4VnCuogbZ9Zs +# 9syJbg7ZUS9SVgYkowRsWv5jV4lbqTD+tG4FzhOwcRQwdb6A8zp2Nnd+s7VdCuYF +# sGgI41ucD8oxVfcAMjF9YX5N2s4mltkqnUe3/htVrnxKKDAwSYliaux2L7gKw+bD +# 1kEZ/5ozLRnJ3jjDkomTrPctokY/KaZ1qub0NUnmOKH+3xUK/plWJK8BOQYuU7gK +# YH7Yy9WSKNlP7pKj6i417+3Na/frInjnBkKRCJ/eYTvBH+s5guezpfQWtU4bNo/j +# 8Qw2vpTQ9w7flhH78Rmwd319+YTmhv7TcxDbWlyteaj4RK2wk3pY1oSz2JPE5PNu +# Nmd9Gmf6oePZgy7Ii9JLLq8SnULV7b+IP0UXRY9q+GdRjM2AEX6msZvvPCIoG0aY +# HQu9wZsKEK2jqvWi8/xdeeeSI9FN6K1w4oVQM4Mwggd6MIIFYqADAgECAgphDpDS +# AAAAAAADMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJVUzETMBEGA1UECBMK +# V2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0 +# IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUm9vdCBDZXJ0aWZpY2F0 +# ZSBBdXRob3JpdHkgMjAxMTAeFw0xMTA3MDgyMDU5MDlaFw0yNjA3MDgyMTA5MDla +# MH4xCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdS +# ZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMT +# H01pY3Jvc29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMTEwggIiMA0GCSqGSIb3DQEB +# AQUAA4ICDwAwggIKAoICAQCr8PpyEBwurdhuqoIQTTS68rZYIZ9CGypr6VpQqrgG +# OBoESbp/wwwe3TdrxhLYC/A4wpkGsMg51QEUMULTiQ15ZId+lGAkbK+eSZzpaF7S +# 35tTsgosw6/ZqSuuegmv15ZZymAaBelmdugyUiYSL+erCFDPs0S3XdjELgN1q2jz +# y23zOlyhFvRGuuA4ZKxuZDV4pqBjDy3TQJP4494HDdVceaVJKecNvqATd76UPe/7 +# 4ytaEB9NViiienLgEjq3SV7Y7e1DkYPZe7J7hhvZPrGMXeiJT4Qa8qEvWeSQOy2u +# M1jFtz7+MtOzAz2xsq+SOH7SnYAs9U5WkSE1JcM5bmR/U7qcD60ZI4TL9LoDho33 +# X/DQUr+MlIe8wCF0JV8YKLbMJyg4JZg5SjbPfLGSrhwjp6lm7GEfauEoSZ1fiOIl +# XdMhSz5SxLVXPyQD8NF6Wy/VI+NwXQ9RRnez+ADhvKwCgl/bwBWzvRvUVUvnOaEP +# 6SNJvBi4RHxF5MHDcnrgcuck379GmcXvwhxX24ON7E1JMKerjt/sW5+v/N2wZuLB +# l4F77dbtS+dJKacTKKanfWeA5opieF+yL4TXV5xcv3coKPHtbcMojyyPQDdPweGF +# RInECUzF1KVDL3SV9274eCBYLBNdYJWaPk8zhNqwiBfenk70lrC8RqBsmNLg1oiM +# CwIDAQABo4IB7TCCAekwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0OBBYEFEhuZOVQ +# BdOCqhc3NyK1bajKdQKVMBkGCSsGAQQBgjcUAgQMHgoAUwB1AGIAQwBBMAsGA1Ud +# DwQEAwIBhjAPBgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFHItOgIxkEO5FAVO +# 4eqnxzHRI4k0MFoGA1UdHwRTMFEwT6BNoEuGSWh0dHA6Ly9jcmwubWljcm9zb2Z0 +# LmNvbS9wa2kvY3JsL3Byb2R1Y3RzL01pY1Jvb0NlckF1dDIwMTFfMjAxMV8wM18y +# Mi5jcmwwXgYIKwYBBQUHAQEEUjBQME4GCCsGAQUFBzAChkJodHRwOi8vd3d3Lm1p +# Y3Jvc29mdC5jb20vcGtpL2NlcnRzL01pY1Jvb0NlckF1dDIwMTFfMjAxMV8wM18y +# Mi5jcnQwgZ8GA1UdIASBlzCBlDCBkQYJKwYBBAGCNy4DMIGDMD8GCCsGAQUFBwIB +# FjNodHRwOi8vd3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2RvY3MvcHJpbWFyeWNw +# cy5odG0wQAYIKwYBBQUHAgIwNB4yIB0ATABlAGcAYQBsAF8AcABvAGwAaQBjAHkA +# XwBzAHQAYQB0AGUAbQBlAG4AdAAuIB0wDQYJKoZIhvcNAQELBQADggIBAGfyhqWY +# 4FR5Gi7T2HRnIpsLlhHhY5KZQpZ90nkMkMFlXy4sPvjDctFtg/6+P+gKyju/R6mj +# 82nbY78iNaWXXWWEkH2LRlBV2AySfNIaSxzzPEKLUtCw/WvjPgcuKZvmPRul1LUd +# d5Q54ulkyUQ9eHoj8xN9ppB0g430yyYCRirCihC7pKkFDJvtaPpoLpWgKj8qa1hJ +# Yx8JaW5amJbkg/TAj/NGK978O9C9Ne9uJa7lryft0N3zDq+ZKJeYTQ49C/IIidYf +# wzIY4vDFLc5bnrRJOQrGCsLGra7lstnbFYhRRVg4MnEnGn+x9Cf43iw6IGmYslmJ +# aG5vp7d0w0AFBqYBKig+gj8TTWYLwLNN9eGPfxxvFX1Fp3blQCplo8NdUmKGwx1j +# NpeG39rz+PIWoZon4c2ll9DuXWNB41sHnIc+BncG0QaxdR8UvmFhtfDcxhsEvt9B +# xw4o7t5lL+yX9qFcltgA1qFGvVnzl6UJS0gQmYAf0AApxbGbpT9Fdx41xtKiop96 +# eiL6SJUfq/tHI4D1nvi/a7dLl+LrdXga7Oo3mXkYS//WsyNodeav+vyL6wuA6mk7 +# r/ww7QRMjt/fdW1jkT3RnVZOT7+AVyKheBEyIXrvQQqxP/uozKRdwaGIm1dxVk5I +# RcBCyZt2WwqASGv9eZ/BvW1taslScxMNelDNMYIVWzCCFVcCAQEwgZUwfjELMAkG +# A1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQx +# HjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEoMCYGA1UEAxMfTWljcm9z +# b2Z0IENvZGUgU2lnbmluZyBQQ0EgMjAxMQITMwAAAVGejY9AcaMOQQAAAAABUTAN +# BglghkgBZQMEAgEFAKCBrjAZBgkqhkiG9w0BCQMxDAYKKwYBBAGCNwIBBDAcBgor +# BgEEAYI3AgELMQ4wDAYKKwYBBAGCNwIBFTAvBgkqhkiG9w0BCQQxIgQgopI8104b +# 0EnVEYmfFHM46WWr+1S2g1Po9V0IIz9E0fIwQgYKKwYBBAGCNwIBDDE0MDKgFIAS +# AE0AaQBjAHIAbwBzAG8AZgB0oRqAGGh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbTAN +# BgkqhkiG9w0BAQEFAASCAQAbuDi4G1Mp1dRBVz6luRSy2tN0iM7/Bb+j6M4iSImO +# OzMWHRz0FMlcUnnc9RRJjqOZ5hpKTCmFhxOUldETpSTW+WlIYMB1wiKf7zGJr2L6 +# QWp7ShHl48JFSQtJSdp6QXsUMKHa1UixMihpRWOXTF4rDSo0gvt+zeh/NFRHapXz +# 0eOt6s8/SOnkqxMIiqgHqFXK+Wa2ultJ9hOtz99NZSFN8CV0EseM5qjb8ARnbYQ+ +# RIP//EF7m5CrhVjKAfx47WpVl7OhIFNriBbqwc4AP4xUiNNWfy7Ld6T7Oc9nYrg8 +# GsmXl6HxRxne6Ca/beIdw+QN+eVr6tchAtov9IpIIU51oYIS5TCCEuEGCisGAQQB +# gjcDAwExghLRMIISzQYJKoZIhvcNAQcCoIISvjCCEroCAQMxDzANBglghkgBZQME +# AgEFADCCAVEGCyqGSIb3DQEJEAEEoIIBQASCATwwggE4AgEBBgorBgEEAYRZCgMB +# MDEwDQYJYIZIAWUDBAIBBQAEIPxSsh/JzqPJ8shEX3wOsZqa/ovJq9xU7VQL6+2r +# 4y4uAgZeK1CJTfwYEzIwMjAwMjExMTkzODA4LjIyMVowBIACAfSggdCkgc0wgcox +# CzAJBgNVBAYTAlVTMQswCQYDVQQIEwJXQTEQMA4GA1UEBxMHUmVkbW9uZDEeMBwG +# A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMS0wKwYDVQQLEyRNaWNyb3NvZnQg +# SXJlbGFuZCBPcGVyYXRpb25zIExpbWl0ZWQxJjAkBgNVBAsTHVRoYWxlcyBUU1Mg +# RVNOOkZDNDEtNEJENC1EMjIwMSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFt +# cCBTZXJ2aWNloIIOPDCCBPEwggPZoAMCAQICEzMAAAESJHOjNYZpEw8AAAAAARIw +# DQYJKoZIhvcNAQELBQAwfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0 +# b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3Jh +# dGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAwHhcN +# MTkxMDIzMjMxOTIxWhcNMjEwMTIxMjMxOTIxWjCByjELMAkGA1UEBhMCVVMxCzAJ +# BgNVBAgTAldBMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQg +# Q29ycG9yYXRpb24xLTArBgNVBAsTJE1pY3Jvc29mdCBJcmVsYW5kIE9wZXJhdGlv +# bnMgTGltaXRlZDEmMCQGA1UECxMdVGhhbGVzIFRTUyBFU046RkM0MS00QkQ0LUQy +# MjAxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2UwggEiMA0G +# CSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQC7qpkVNy9QRHdwpRC0gj59zrmITeD+ +# fM4UFZeWGrSdq2sSFiIVtqfrIHaDreibRBsw04V4wcmpgabdlF+8GBNg4RZ+aPIp +# K2k25QpMGYVJLFSSsbEOOBWSHIqQYfxxybS0Ltun3qGdBqJsfdhlfMq+L4E3aAly +# xEwfk9qDZuHUV3y9EyUwfHLDhsEpZbCkpNIbjRvFsa7wJd6woZEBBd1pJe7myYmr +# KMlzeXhv0k3GwjvtvTl+0TCdTgjCpYpotp+BANqpUH2ScWZpWeue8FL7aIDditVu +# eTGWXnUKvceE9PAvQiUJsqbKflXMpL5MIHtJST56axWt5apNwyP+rx4JAgMBAAGj +# ggEbMIIBFzAdBgNVHQ4EFgQU0jpwHwtMSWArKrTCMswGxnZlW10wHwYDVR0jBBgw +# FoAU1WM6XIoxkPNDe3xGG8UzaFqFbVUwVgYDVR0fBE8wTTBLoEmgR4ZFaHR0cDov +# L2NybC5taWNyb3NvZnQuY29tL3BraS9jcmwvcHJvZHVjdHMvTWljVGltU3RhUENB +# XzIwMTAtMDctMDEuY3JsMFoGCCsGAQUFBwEBBE4wTDBKBggrBgEFBQcwAoY+aHR0 +# cDovL3d3dy5taWNyb3NvZnQuY29tL3BraS9jZXJ0cy9NaWNUaW1TdGFQQ0FfMjAx +# MC0wNy0wMS5jcnQwDAYDVR0TAQH/BAIwADATBgNVHSUEDDAKBggrBgEFBQcDCDAN +# BgkqhkiG9w0BAQsFAAOCAQEAOREvr5Gv0gr1Y0WWJy7PoZC5lfdhA5vnnYv7/xAs +# 2WuV/dUBF1EvE/2pz61Ey6LpjTofrExP5ZKHCatVW0tNQREj4a+Rz2FnPv+gx+VR +# Vl7WwPaZXdIiXwhmUKvcot5dcaUVUQVxxgBzcnTLc/9nCE//E6oZw8QrMPCtrioq +# 6EiIExeTkTubO7YsvTz3dmBdDfc/gIFtfHp7TFajgS/fy2I6tdMratQCed05LJg5 +# oSIWYvFs0xawxi6aoMN1FqnC21xAIRAEmODN09V/0xrrpqkqIp8OU/3zuLQK+BRm +# bp8rSAP+2BOAHbRq00kaGobd5qt30fFsPaqG6bwn2L1H4DCCBnEwggRZoAMCAQIC +# CmEJgSoAAAAAAAIwDQYJKoZIhvcNAQELBQAwgYgxCzAJBgNVBAYTAlVTMRMwEQYD +# VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy +# b3NvZnQgQ29ycG9yYXRpb24xMjAwBgNVBAMTKU1pY3Jvc29mdCBSb290IENlcnRp +# ZmljYXRlIEF1dGhvcml0eSAyMDEwMB4XDTEwMDcwMTIxMzY1NVoXDTI1MDcwMTIx +# NDY1NVowfDELMAkGA1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNV +# BAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQG +# A1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAwggEiMA0GCSqGSIb3 +# DQEBAQUAA4IBDwAwggEKAoIBAQCpHQ28dxGKOiDs/BOX9fp/aZRrdFQQ1aUKAIKF +# ++18aEssX8XD5WHCdrc+Zitb8BVTJwQxH0EbGpUdzgkTjnxhMFmxMEQP8WCIhFRD +# DNdNuDgIs0Ldk6zWczBXJoKjRQ3Q6vVHgc2/JGAyWGBG8lhHhjKEHnRhZ5FfgVSx +# z5NMksHEpl3RYRNuKMYa+YaAu99h/EbBJx0kZxJyGiGKr0tkiVBisV39dx898Fd1 +# rL2KQk1AUdEPnAY+Z3/1ZsADlkR+79BL/W7lmsqxqPJ6Kgox8NpOBpG2iAg16Hgc +# sOmZzTznL0S6p/TcZL2kAcEgCZN4zfy8wMlEXV4WnAEFTyJNAgMBAAGjggHmMIIB +# 4jAQBgkrBgEEAYI3FQEEAwIBADAdBgNVHQ4EFgQU1WM6XIoxkPNDe3xGG8UzaFqF +# bVUwGQYJKwYBBAGCNxQCBAweCgBTAHUAYgBDAEEwCwYDVR0PBAQDAgGGMA8GA1Ud +# EwEB/wQFMAMBAf8wHwYDVR0jBBgwFoAU1fZWy4/oolxiaNE9lJBb186aGMQwVgYD +# VR0fBE8wTTBLoEmgR4ZFaHR0cDovL2NybC5taWNyb3NvZnQuY29tL3BraS9jcmwv +# cHJvZHVjdHMvTWljUm9vQ2VyQXV0XzIwMTAtMDYtMjMuY3JsMFoGCCsGAQUFBwEB +# BE4wTDBKBggrBgEFBQcwAoY+aHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraS9j +# ZXJ0cy9NaWNSb29DZXJBdXRfMjAxMC0wNi0yMy5jcnQwgaAGA1UdIAEB/wSBlTCB +# kjCBjwYJKwYBBAGCNy4DMIGBMD0GCCsGAQUFBwIBFjFodHRwOi8vd3d3Lm1pY3Jv +# c29mdC5jb20vUEtJL2RvY3MvQ1BTL2RlZmF1bHQuaHRtMEAGCCsGAQUFBwICMDQe +# MiAdAEwAZQBnAGEAbABfAFAAbwBsAGkAYwB5AF8AUwB0AGEAdABlAG0AZQBuAHQA +# LiAdMA0GCSqGSIb3DQEBCwUAA4ICAQAH5ohRDeLG4Jg/gXEDPZ2joSFvs+umzPUx +# vs8F4qn++ldtGTCzwsVmyWrf9efweL3HqJ4l4/m87WtUVwgrUYJEEvu5U4zM9GAS +# inbMQEBBm9xcF/9c+V4XNZgkVkt070IQyK+/f8Z/8jd9Wj8c8pl5SpFSAK84Dxf1 +# L3mBZdmptWvkx872ynoAb0swRCQiPM/tA6WWj1kpvLb9BOFwnzJKJ/1Vry/+tuWO +# M7tiX5rbV0Dp8c6ZZpCM/2pif93FSguRJuI57BlKcWOdeyFtw5yjojz6f32WapB4 +# pm3S4Zz5Hfw42JT0xqUKloakvZ4argRCg7i1gJsiOCC1JeVk7Pf0v35jWSUPei45 +# V3aicaoGig+JFrphpxHLmtgOR5qAxdDNp9DvfYPw4TtxCd9ddJgiCGHasFAeb73x +# 4QDf5zEHpJM692VHeOj4qEir995yfmFrb3epgcunCaw5u+zGy9iCtHLNHfS4hQEe +# gPsbiSpUObJb2sgNVZl6h3M7COaYLeqN4DMuEin1wC9UJyH3yKxO2ii4sanblrKn +# QqLJzxlBTeCG+SqaoxFmMNO7dDJL32N79ZmKLxvHIa9Zta7cRDyXUHHXodLFVeNp +# 3lfB0d4wwP3M5k37Db9dT+mdHhk4L7zPWAUu7w2gUDXa7wknHNWzfjUeCLraNtvT +# X4/edIhJEqGCAs4wggI3AgEBMIH4oYHQpIHNMIHKMQswCQYDVQQGEwJVUzELMAkG +# A1UECBMCV0ExEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29mdCBD +# b3Jwb3JhdGlvbjEtMCsGA1UECxMkTWljcm9zb2Z0IElyZWxhbmQgT3BlcmF0aW9u +# cyBMaW1pdGVkMSYwJAYDVQQLEx1UaGFsZXMgVFNTIEVTTjpGQzQxLTRCRDQtRDIy +# MDElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUtU3RhbXAgU2VydmljZaIjCgEBMAcG +# BSsOAwIaAxUAEuAr1oQqvtDynteleDjFzDMCiryggYMwgYCkfjB8MQswCQYDVQQG +# EwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwG +# A1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYDVQQDEx1NaWNyb3NvZnQg +# VGltZS1TdGFtcCBQQ0EgMjAxMDANBgkqhkiG9w0BAQUFAAIFAOHs4QowIhgPMjAy +# MDAyMTExNjE1MDZaGA8yMDIwMDIxMjE2MTUwNlowdzA9BgorBgEEAYRZCgQBMS8w +# LTAKAgUA4ezhCgIBADAKAgEAAgIkewIB/zAHAgEAAgIR1zAKAgUA4e4yigIBADA2 +# BgorBgEEAYRZCgQCMSgwJjAMBgorBgEEAYRZCgMCoAowCAIBAAIDB6EgoQowCAIB +# AAIDAYagMA0GCSqGSIb3DQEBBQUAA4GBAIwGO1FkJkrPGRodAEs4EMyYtLFEBOxn +# uFqJ9U81AwCUa0yNLfA9B2XAeSOcKR3ZdJRLo2LFKwluNYEH0VQgK7ejqfF1DPU4 +# XBczAKHLywBdw2qCIU8LxijEQ4MJzM4QII0BkM2lmUEqD8Muo/Dt8RckacAHZZ0q +# aWZ6sdqW+RY+MYIDDTCCAwkCAQEwgZMwfDELMAkGA1UEBhMCVVMxEzARBgNVBAgT +# Cldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jvc29m +# dCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9zb2Z0IFRpbWUtU3RhbXAgUENB +# IDIwMTACEzMAAAESJHOjNYZpEw8AAAAAARIwDQYJYIZIAWUDBAIBBQCgggFKMBoG +# CSqGSIb3DQEJAzENBgsqhkiG9w0BCRABBDAvBgkqhkiG9w0BCQQxIgQgmnp4W+66 +# z9Vf9/WgM5+tpn5qOBBjKP7eNkR2afQaVtQwgfoGCyqGSIb3DQEJEAIvMYHqMIHn +# MIHkMIG9BCAkErCCv+giGoqpOcgpeGLYpPsnfskYMI+DETR1lzqWUzCBmDCBgKR+ +# MHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdS +# ZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMT +# HU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwAhMzAAABEiRzozWGaRMPAAAA +# AAESMCIEICm0lvYFK1Lk8ptCSgsYMS7llBWI6KznzAODsB42qaB5MA0GCSqGSIb3 +# DQEBCwUABIIBAJfR7DZ4iiFmQUbdLVGeOdJxPbgCDDOWjxYXwvLVQls690H/RIMz +# pdTCCe3UDSeCNzeQgQBSwpSD0buz6xqDVR9IjS0+C1YZzleaqFq1r1Cf0+eAzcWS +# 4cewxeZpdGt6afYBVwDIr7PppBTYPTFbJousPjiQyMJgcflQfGw17S/pE4U631mB +# WuMwWxv5dlVCDXlkqWsf1jhAx8ovhxFivudnNK6c1BT1cWqeDyX5oB4nuqf/tiTh +# sLpbF18liJAh6NWKbdUe2mPSyiMGAz752kdXBBbrz67d3ZfkntfQl2LBM+JgesgQ +# DG00QV4LXa6oP3x7V1TqeMc3b7XAc1R/eAg= +# SIG # End signature block diff --git a/src/NodePilot.Engine/PowerShell/Modules/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psm1 b/src/NodePilot.Engine/PowerShell/Modules/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psm1 new file mode 100644 index 00000000..630da7e3 --- /dev/null +++ b/src/NodePilot.Engine/PowerShell/Modules/Microsoft.PowerShell.Archive/Microsoft.PowerShell.Archive.psm1 @@ -0,0 +1,1505 @@ +data LocalizedData +{ + # culture="en-US" + ConvertFrom-StringData @' + PathNotFoundError=The path '{0}' either does not exist or is not a valid file system path. + ExpandArchiveInValidDestinationPath=The path '{0}' is not a valid file system directory path. + InvalidZipFileExtensionError={0} is not a supported archive file format. {1} is the only supported archive file format. + ArchiveFileIsReadOnly=The attributes of the archive file {0} is set to 'ReadOnly' hence it cannot be updated. If you intend to update the existing archive file, remove the 'ReadOnly' attribute on the archive file else use -Force parameter to override and create a new archive file. + ZipFileExistError=The archive file {0} already exists. Use the -Update parameter to update the existing archive file or use the -Force parameter to overwrite the existing archive file. + DuplicatePathFoundError=The input to {0} parameter contains a duplicate path '{1}'. Provide a unique set of paths as input to {2} parameter. + ArchiveFileIsEmpty=The archive file {0} is empty. + CompressProgressBarText=The archive file '{0}' creation is in progress... + ExpandProgressBarText=The archive file '{0}' expansion is in progress... + AppendArchiveFileExtensionMessage=The archive file path '{0}' supplied to the DestinationPath parameter does not include .zip extension. Hence .zip is appended to the supplied DestinationPath path and the archive file would be created at '{1}'. + AddItemtoArchiveFile=Adding '{0}'. + BadArchiveEntry=Can not process invalid archive entry '{0}'. + CreateFileAtExpandedPath=Created '{0}'. + InvalidArchiveFilePathError=The archive file path '{0}' specified as input to the {1} parameter is resolving to multiple file system paths. Provide a unique path to the {2} parameter where the archive file has to be created. + InvalidExpandedDirPathError=The directory path '{0}' specified as input to the DestinationPath parameter is resolving to multiple file system paths. Provide a unique path to the Destination parameter where the archive file contents have to be expanded. + FileExistsError=Failed to create file '{0}' while expanding the archive file '{1}' contents as the file '{2}' already exists. Use the -Force parameter if you want to overwrite the existing directory '{3}' contents when expanding the archive file. + DeleteArchiveFile=The partially created archive file '{0}' is deleted as it is not usable. + InvalidDestinationPath=The destination path '{0}' does not contain a valid archive file name. + PreparingToCompressVerboseMessage=Preparing to compress... + PreparingToExpandVerboseMessage=Preparing to expand... + ItemDoesNotAppearToBeAValidZipArchive=File '{0}' does not appear to be a valid zip archive. +'@ +} + +Import-LocalizedData LocalizedData -filename ArchiveResources -ErrorAction Ignore + +$zipFileExtension = ".zip" + +<############################################################################################ +# The Compress-Archive cmdlet can be used to zip/compress one or more files/directories. +############################################################################################> +function Compress-Archive +{ + [CmdletBinding( + DefaultParameterSetName="Path", + SupportsShouldProcess=$true, + HelpUri="https://go.microsoft.com/fwlink/?linkid=2096473")] + [OutputType([System.IO.File])] + param + ( + [parameter (mandatory=$true, Position=0, ParameterSetName="Path", ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] + [parameter (mandatory=$true, Position=0, ParameterSetName="PathWithForce", ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] + [parameter (mandatory=$true, Position=0, ParameterSetName="PathWithUpdate", ValueFromPipeline=$true, ValueFromPipelineByPropertyName=$true)] + [ValidateNotNullOrEmpty()] + [string[]] $Path, + + [parameter (mandatory=$true, ParameterSetName="LiteralPath", ValueFromPipeline=$false, ValueFromPipelineByPropertyName=$true)] + [parameter (mandatory=$true, ParameterSetName="LiteralPathWithForce", ValueFromPipeline=$false, ValueFromPipelineByPropertyName=$true)] + [parameter (mandatory=$true, ParameterSetName="LiteralPathWithUpdate", ValueFromPipeline=$false, ValueFromPipelineByPropertyName=$true)] + [ValidateNotNullOrEmpty()] + [Alias("PSPath")] + [string[]] $LiteralPath, + + [parameter (mandatory=$true, + Position=1, + ValueFromPipeline=$false, + ValueFromPipelineByPropertyName=$false)] + [ValidateNotNullOrEmpty()] + [string] $DestinationPath, + + [parameter ( + mandatory=$false, + ValueFromPipeline=$false, + ValueFromPipelineByPropertyName=$false)] + [ValidateSet("Optimal","NoCompression","Fastest")] + [string] + $CompressionLevel = "Optimal", + + [parameter(mandatory=$true, ParameterSetName="PathWithUpdate", ValueFromPipeline=$false, ValueFromPipelineByPropertyName=$false)] + [parameter(mandatory=$true, ParameterSetName="LiteralPathWithUpdate", ValueFromPipeline=$false, ValueFromPipelineByPropertyName=$false)] + [switch] + $Update = $false, + + [parameter(mandatory=$true, ParameterSetName="PathWithForce", ValueFromPipeline=$false, ValueFromPipelineByPropertyName=$false)] + [parameter(mandatory=$true, ParameterSetName="LiteralPathWithForce", ValueFromPipeline=$false, ValueFromPipelineByPropertyName=$false)] + [switch] + $Force = $false, + + [switch] + $PassThru = $false + ) + + BEGIN + { + # Ensure the destination path is in a non-PS-specific format + $DestinationPath = $PSCmdlet.GetUnresolvedProviderPathFromPSPath($DestinationPath) + + $inputPaths = @() + $destinationParentDir = [system.IO.Path]::GetDirectoryName($DestinationPath) + if($null -eq $destinationParentDir) + { + $errorMessage = ($LocalizedData.InvalidDestinationPath -f $DestinationPath) + ThrowTerminatingErrorHelper "InvalidArchiveFilePath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $DestinationPath + } + + if($destinationParentDir -eq [string]::Empty) + { + $destinationParentDir = '.' + } + + $archiveFileName = [system.IO.Path]::GetFileName($DestinationPath) + $destinationParentDir = GetResolvedPathHelper $destinationParentDir $false $PSCmdlet + + if($destinationParentDir.Count -gt 1) + { + $errorMessage = ($LocalizedData.InvalidArchiveFilePathError -f $DestinationPath, "DestinationPath", "DestinationPath") + ThrowTerminatingErrorHelper "InvalidArchiveFilePath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $DestinationPath + } + + IsValidFileSystemPath $destinationParentDir | Out-Null + $DestinationPath = Join-Path -Path $destinationParentDir -ChildPath $archiveFileName + + # GetExtension API does not validate for the actual existence of the path. + $extension = [system.IO.Path]::GetExtension($DestinationPath) + + # If user does not specify an extension, we append the .zip extension automatically. + If($extension -eq [string]::Empty) + { + $DestinationPathWithOutExtension = $DestinationPath + $DestinationPath = $DestinationPathWithOutExtension + $zipFileExtension + $appendArchiveFileExtensionMessage = ($LocalizedData.AppendArchiveFileExtensionMessage -f $DestinationPathWithOutExtension, $DestinationPath) + Write-Verbose $appendArchiveFileExtensionMessage + } + + $archiveFileExist = Test-Path -LiteralPath $DestinationPath -PathType Leaf + + if($archiveFileExist -and ($Update -eq $false -and $Force -eq $false)) + { + $errorMessage = ($LocalizedData.ZipFileExistError -f $DestinationPath) + ThrowTerminatingErrorHelper "ArchiveFileExists" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $DestinationPath + } + + # If archive file already exists and if -Update is specified, then we check to see + # if we have write access permission to update the existing archive file. + if($archiveFileExist -and $Update -eq $true) + { + $item = Get-Item -Path $DestinationPath + if($item.Attributes.ToString().Contains("ReadOnly")) + { + $errorMessage = ($LocalizedData.ArchiveFileIsReadOnly -f $DestinationPath) + ThrowTerminatingErrorHelper "ArchiveFileIsReadOnly" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidOperation) $DestinationPath + } + } + + $isWhatIf = $psboundparameters.ContainsKey("WhatIf") + if(!$isWhatIf) + { + $preparingToCompressVerboseMessage = ($LocalizedData.PreparingToCompressVerboseMessage) + Write-Verbose $preparingToCompressVerboseMessage + + $progressBarStatus = ($LocalizedData.CompressProgressBarText -f $DestinationPath) + ProgressBarHelper "Compress-Archive" $progressBarStatus 0 100 100 1 + } + } + PROCESS + { + if($PsCmdlet.ParameterSetName -eq "Path" -or + $PsCmdlet.ParameterSetName -eq "PathWithForce" -or + $PsCmdlet.ParameterSetName -eq "PathWithUpdate") + { + $inputPaths += $Path + } + + if($PsCmdlet.ParameterSetName -eq "LiteralPath" -or + $PsCmdlet.ParameterSetName -eq "LiteralPathWithForce" -or + $PsCmdlet.ParameterSetName -eq "LiteralPathWithUpdate") + { + $inputPaths += $LiteralPath + } + } + END + { + # If archive file already exists and if -Force is specified, we delete the + # existing archive file and create a brand new one. + if(($PsCmdlet.ParameterSetName -eq "PathWithForce" -or + $PsCmdlet.ParameterSetName -eq "LiteralPathWithForce") -and $archiveFileExist) + { + Remove-Item -Path $DestinationPath -Force -ErrorAction Stop + } + + # Validate Source Path depending on parameter set being used. + # The specified source path contains one or more files or directories that needs + # to be compressed. + $isLiteralPathUsed = $false + if($PsCmdlet.ParameterSetName -eq "LiteralPath" -or + $PsCmdlet.ParameterSetName -eq "LiteralPathWithForce" -or + $PsCmdlet.ParameterSetName -eq "LiteralPathWithUpdate") + { + $isLiteralPathUsed = $true + } + + ValidateDuplicateFileSystemPath $PsCmdlet.ParameterSetName $inputPaths + $resolvedPaths = GetResolvedPathHelper $inputPaths $isLiteralPathUsed $PSCmdlet + IsValidFileSystemPath $resolvedPaths | Out-Null + + $sourcePath = $resolvedPaths; + + # CSVHelper: This is a helper function used to append comma after each path specified by + # the $sourcePath array. The comma separated paths are displayed in the -WhatIf message. + $sourcePathInCsvFormat = CSVHelper $sourcePath + if($pscmdlet.ShouldProcess($sourcePathInCsvFormat)) + { + try + { + # StopProcessing is not available in Script cmdlets. However the pipeline execution + # is terminated when ever 'CTRL + C' is entered by user to terminate the cmdlet execution. + # The finally block is executed whenever pipeline is terminated. + # $isArchiveFileProcessingComplete variable is used to track if 'CTRL + C' is entered by the + # user. + $isArchiveFileProcessingComplete = $false + + $numberOfItemsArchived = CompressArchiveHelper $sourcePath $DestinationPath $CompressionLevel $Update + + $isArchiveFileProcessingComplete = $true + } + finally + { + # The $isArchiveFileProcessingComplete would be set to $false if user has typed 'CTRL + C' to + # terminate the cmdlet execution or if an unhandled exception is thrown. + # $numberOfItemsArchived contains the count of number of files or directories add to the archive file. + # If the newly created archive file is empty then we delete it as it's not usable. + if(($isArchiveFileProcessingComplete -eq $false) -or + ($numberOfItemsArchived -eq 0)) + { + $DeleteArchiveFileMessage = ($LocalizedData.DeleteArchiveFile -f $DestinationPath) + Write-Verbose $DeleteArchiveFileMessage + + # delete the partial archive file created. + if (Test-Path $DestinationPath) { + Remove-Item -LiteralPath $DestinationPath -Force -Recurse -ErrorAction SilentlyContinue + } + } + elseif ($PassThru) + { + Get-Item -LiteralPath $DestinationPath + } + } + } + } +} + +<############################################################################################ +# The Expand-Archive cmdlet can be used to expand/extract an zip file. +############################################################################################> +function Expand-Archive +{ + [CmdletBinding( + DefaultParameterSetName="Path", + SupportsShouldProcess=$true, + HelpUri="https://go.microsoft.com/fwlink/?linkid=2096769")] + [OutputType([System.IO.FileSystemInfo])] + param + ( + [parameter ( + mandatory=$true, + Position=0, + ParameterSetName="Path", + ValueFromPipeline=$true, + ValueFromPipelineByPropertyName=$true)] + [ValidateNotNullOrEmpty()] + [string] $Path, + + [parameter ( + mandatory=$true, + ParameterSetName="LiteralPath", + ValueFromPipelineByPropertyName=$true)] + [ValidateNotNullOrEmpty()] + [Alias("PSPath")] + [string] $LiteralPath, + + [parameter (mandatory=$false, + Position=1, + ValueFromPipeline=$false, + ValueFromPipelineByPropertyName=$false)] + [ValidateNotNullOrEmpty()] + [string] $DestinationPath, + + [parameter (mandatory=$false, + ValueFromPipeline=$false, + ValueFromPipelineByPropertyName=$false)] + [switch] $Force, + + [switch] + $PassThru = $false + ) + + BEGIN + { + $isVerbose = $psboundparameters.ContainsKey("Verbose") + $isConfirm = $psboundparameters.ContainsKey("Confirm") + + $isDestinationPathProvided = $true + if($DestinationPath -eq [string]::Empty) + { + $resolvedDestinationPath = (Get-Location).ProviderPath + $isDestinationPathProvided = $false + } + else + { + $destinationPathExists = Test-Path -Path $DestinationPath -PathType Container + if($destinationPathExists) + { + $resolvedDestinationPath = GetResolvedPathHelper $DestinationPath $false $PSCmdlet + if($resolvedDestinationPath.Count -gt 1) + { + $errorMessage = ($LocalizedData.InvalidExpandedDirPathError -f $DestinationPath) + ThrowTerminatingErrorHelper "InvalidDestinationPath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $DestinationPath + } + + # At this point we are sure that the provided path resolves to a valid single path. + # Calling Resolve-Path again to get the underlying provider name. + $suppliedDestinationPath = Resolve-Path -Path $DestinationPath + if($suppliedDestinationPath.Provider.Name-ne "FileSystem") + { + $errorMessage = ($LocalizedData.ExpandArchiveInValidDestinationPath -f $DestinationPath) + ThrowTerminatingErrorHelper "InvalidDirectoryPath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $DestinationPath + } + } + else + { + $createdItem = New-Item -Path $DestinationPath -ItemType Directory -Confirm:$isConfirm -Verbose:$isVerbose -ErrorAction Stop + if($createdItem -ne $null -and $createdItem.PSProvider.Name -ne "FileSystem") + { + Remove-Item "$DestinationPath" -Force -Recurse -ErrorAction SilentlyContinue + $errorMessage = ($LocalizedData.ExpandArchiveInValidDestinationPath -f $DestinationPath) + ThrowTerminatingErrorHelper "InvalidDirectoryPath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $DestinationPath + } + + $resolvedDestinationPath = GetResolvedPathHelper $DestinationPath $true $PSCmdlet + } + } + + $isWhatIf = $psboundparameters.ContainsKey("WhatIf") + if(!$isWhatIf) + { + $preparingToExpandVerboseMessage = ($LocalizedData.PreparingToExpandVerboseMessage) + Write-Verbose $preparingToExpandVerboseMessage + + $progressBarStatus = ($LocalizedData.ExpandProgressBarText -f $DestinationPath) + ProgressBarHelper "Expand-Archive" $progressBarStatus 0 100 100 1 + } + } + PROCESS + { + switch($PsCmdlet.ParameterSetName) + { + "Path" + { + $resolvedSourcePaths = GetResolvedPathHelper $Path $false $PSCmdlet + + if($resolvedSourcePaths.Count -gt 1) + { + $errorMessage = ($LocalizedData.InvalidArchiveFilePathError -f $Path, $PsCmdlet.ParameterSetName, $PsCmdlet.ParameterSetName) + ThrowTerminatingErrorHelper "InvalidArchiveFilePath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $Path + } + } + "LiteralPath" + { + $resolvedSourcePaths = GetResolvedPathHelper $LiteralPath $true $PSCmdlet + + if($resolvedSourcePaths.Count -gt 1) + { + $errorMessage = ($LocalizedData.InvalidArchiveFilePathError -f $LiteralPath, $PsCmdlet.ParameterSetName, $PsCmdlet.ParameterSetName) + ThrowTerminatingErrorHelper "InvalidArchiveFilePath" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $LiteralPath + } + } + } + + ValidateArchivePathHelper $resolvedSourcePaths + + if($pscmdlet.ShouldProcess($resolvedSourcePaths)) + { + $expandedItems = @() + + try + { + # StopProcessing is not available in Script cmdlets. However the pipeline execution + # is terminated when ever 'CTRL + C' is entered by user to terminate the cmdlet execution. + # The finally block is executed whenever pipeline is terminated. + # $isArchiveFileProcessingComplete variable is used to track if 'CTRL + C' is entered by the + # user. + $isArchiveFileProcessingComplete = $false + + # The User has not provided a destination path, hence we use '$pwd\ArchiveFileName' as the directory where the + # archive file contents would be expanded. If the path '$pwd\ArchiveFileName' already exists then we use the + # Windows default mechanism of appending a counter value at the end of the directory name where the contents + # would be expanded. + if(!$isDestinationPathProvided) + { + $archiveFile = New-Object System.IO.FileInfo $resolvedSourcePaths + $resolvedDestinationPath = Join-Path -Path $resolvedDestinationPath -ChildPath $archiveFile.BaseName + $destinationPathExists = Test-Path -LiteralPath $resolvedDestinationPath -PathType Container + + if(!$destinationPathExists) + { + New-Item -Path $resolvedDestinationPath -ItemType Directory -Confirm:$isConfirm -Verbose:$isVerbose -ErrorAction Stop | Out-Null + } + } + + ExpandArchiveHelper $resolvedSourcePaths $resolvedDestinationPath ([ref]$expandedItems) $Force $isVerbose $isConfirm + + $isArchiveFileProcessingComplete = $true + } + finally + { + # The $isArchiveFileProcessingComplete would be set to $false if user has typed 'CTRL + C' to + # terminate the cmdlet execution or if an unhandled exception is thrown. + if($isArchiveFileProcessingComplete -eq $false) + { + if($expandedItems.Count -gt 0) + { + # delete the expanded file/directory as the archive + # file was not completely expanded. + $expandedItems | % { Remove-Item "$_" -Force -Recurse } + } + } + elseif ($PassThru -and $expandedItems.Count -gt 0) + { + # Return the expanded items, being careful to remove trailing directory separators from + # any folder paths for consistency + $trailingDirSeparators = '\' + [System.IO.Path]::DirectorySeparatorChar + '+$' + Get-Item -LiteralPath ($expandedItems -replace $trailingDirSeparators) + } + } + } + } +} + +<############################################################################################ +# GetResolvedPathHelper: This is a helper function used to resolve the user specified Path. +# The path can either be absolute or relative path. +############################################################################################> +function GetResolvedPathHelper +{ + param + ( + [string[]] $path, + [boolean] $isLiteralPath, + [System.Management.Automation.PSCmdlet] + $callerPSCmdlet + ) + + $resolvedPaths =@() + + # null and empty check are are already done on Path parameter at the cmdlet layer. + foreach($currentPath in $path) + { + try + { + if($isLiteralPath) + { + $currentResolvedPaths = Resolve-Path -LiteralPath $currentPath -ErrorAction Stop + } + else + { + $currentResolvedPaths = Resolve-Path -Path $currentPath -ErrorAction Stop + } + } + catch + { + $errorMessage = ($LocalizedData.PathNotFoundError -f $currentPath) + $exception = New-Object System.InvalidOperationException $errorMessage, $_.Exception + $errorRecord = CreateErrorRecordHelper "ArchiveCmdletPathNotFound" $null ([System.Management.Automation.ErrorCategory]::InvalidArgument) $exception $currentPath + $callerPSCmdlet.ThrowTerminatingError($errorRecord) + } + + foreach($currentResolvedPath in $currentResolvedPaths) + { + $resolvedPaths += $currentResolvedPath.ProviderPath + } + } + + $resolvedPaths +} + +function Add-CompressionAssemblies { + Add-Type -AssemblyName System.IO.Compression + if ($psedition -eq "Core") + { + Add-Type -AssemblyName System.IO.Compression.ZipFile + } + else + { + Add-Type -AssemblyName System.IO.Compression.FileSystem + } +} + +function IsValidFileSystemPath +{ + param + ( + [string[]] $path + ) + + $result = $true; + + # null and empty check are are already done on Path parameter at the cmdlet layer. + foreach($currentPath in $path) + { + if(!([System.IO.File]::Exists($currentPath) -or [System.IO.Directory]::Exists($currentPath))) + { + $errorMessage = ($LocalizedData.PathNotFoundError -f $currentPath) + ThrowTerminatingErrorHelper "PathNotFound" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $currentPath + } + } + + return $result; +} + + +function ValidateDuplicateFileSystemPath +{ + param + ( + [string] $inputParameter, + [string[]] $path + ) + + $uniqueInputPaths = @() + + # null and empty check are are already done on Path parameter at the cmdlet layer. + foreach($currentPath in $path) + { + $currentInputPath = $currentPath.ToUpper() + if($uniqueInputPaths.Contains($currentInputPath)) + { + $errorMessage = ($LocalizedData.DuplicatePathFoundError -f $inputParameter, $currentPath, $inputParameter) + ThrowTerminatingErrorHelper "DuplicatePathFound" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $currentPath + } + else + { + $uniqueInputPaths += $currentInputPath + } + } +} + +function CompressionLevelMapper +{ + param + ( + [string] $compressionLevel + ) + + $compressionLevelFormat = [System.IO.Compression.CompressionLevel]::Optimal + + # CompressionLevel format is already validated at the cmdlet layer. + switch($compressionLevel.ToString()) + { + "Fastest" + { + $compressionLevelFormat = [System.IO.Compression.CompressionLevel]::Fastest + } + "NoCompression" + { + $compressionLevelFormat = [System.IO.Compression.CompressionLevel]::NoCompression + } + } + + return $compressionLevelFormat +} + +function CompressArchiveHelper +{ + param + ( + [string[]] $sourcePath, + [string] $destinationPath, + [string] $compressionLevel, + [bool] $isUpdateMode + ) + + $numberOfItemsArchived = 0 + $sourceFilePaths = @() + $sourceDirPaths = @() + + foreach($currentPath in $sourcePath) + { + $result = Test-Path -LiteralPath $currentPath -Type Leaf + if($result -eq $true) + { + $sourceFilePaths += $currentPath + } + else + { + $sourceDirPaths += $currentPath + } + } + + # The Source Path contains one or more directory (this directory can have files under it) and no files to be compressed. + if($sourceFilePaths.Count -eq 0 -and $sourceDirPaths.Count -gt 0) + { + $currentSegmentWeight = 100/[double]$sourceDirPaths.Count + $previousSegmentWeight = 0 + foreach($currentSourceDirPath in $sourceDirPaths) + { + $count = CompressSingleDirHelper $currentSourceDirPath $destinationPath $compressionLevel $true $isUpdateMode $previousSegmentWeight $currentSegmentWeight + $numberOfItemsArchived += $count + $previousSegmentWeight += $currentSegmentWeight + } + } + + # The Source Path contains only files to be compressed. + elseIf($sourceFilePaths.Count -gt 0 -and $sourceDirPaths.Count -eq 0) + { + # $previousSegmentWeight is equal to 0 as there are no prior segments. + # $currentSegmentWeight is set to 100 as all files have equal weightage. + $previousSegmentWeight = 0 + $currentSegmentWeight = 100 + + $numberOfItemsArchived = CompressFilesHelper $sourceFilePaths $destinationPath $compressionLevel $isUpdateMode $previousSegmentWeight $currentSegmentWeight + } + # The Source Path contains one or more files and one or more directories (this directory can have files under it) to be compressed. + elseif($sourceFilePaths.Count -gt 0 -and $sourceDirPaths.Count -gt 0) + { + # each directory is considered as an individual segments & all the individual files are clubed in to a separate segment. + $currentSegmentWeight = 100/[double]($sourceDirPaths.Count +1) + $previousSegmentWeight = 0 + + foreach($currentSourceDirPath in $sourceDirPaths) + { + $count = CompressSingleDirHelper $currentSourceDirPath $destinationPath $compressionLevel $true $isUpdateMode $previousSegmentWeight $currentSegmentWeight + $numberOfItemsArchived += $count + $previousSegmentWeight += $currentSegmentWeight + } + + $count = CompressFilesHelper $sourceFilePaths $destinationPath $compressionLevel $isUpdateMode $previousSegmentWeight $currentSegmentWeight + $numberOfItemsArchived += $count + } + + return $numberOfItemsArchived +} + +function CompressFilesHelper +{ + param + ( + [string[]] $sourceFilePaths, + [string] $destinationPath, + [string] $compressionLevel, + [bool] $isUpdateMode, + [double] $previousSegmentWeight, + [double] $currentSegmentWeight + ) + + $numberOfItemsArchived = ZipArchiveHelper $sourceFilePaths $destinationPath $compressionLevel $isUpdateMode $null $previousSegmentWeight $currentSegmentWeight + + return $numberOfItemsArchived +} + +function CompressSingleDirHelper +{ + param + ( + [string] $sourceDirPath, + [string] $destinationPath, + [string] $compressionLevel, + [bool] $useParentDirAsRoot, + [bool] $isUpdateMode, + [double] $previousSegmentWeight, + [double] $currentSegmentWeight + ) + + [System.Collections.Generic.List[System.String]]$subDirFiles = @() + + if($useParentDirAsRoot) + { + $sourceDirInfo = New-Object -TypeName System.IO.DirectoryInfo -ArgumentList $sourceDirPath + $sourceDirFullName = $sourceDirInfo.Parent.FullName + + # If the directory is present at the drive level the DirectoryInfo.Parent include directory separator. example: C:\ + # On the other hand if the directory exists at a deper level then DirectoryInfo.Parent + # has just the path (without an ending directory separator). example C:\source + if($sourceDirFullName.Length -eq 3) + { + $modifiedSourceDirFullName = $sourceDirFullName + } + else + { + $modifiedSourceDirFullName = $sourceDirFullName + [System.IO.Path]::DirectorySeparatorChar + } + } + else + { + $sourceDirFullName = $sourceDirPath + $modifiedSourceDirFullName = $sourceDirFullName + [System.IO.Path]::DirectorySeparatorChar + } + + $dirContents = Get-ChildItem -LiteralPath $sourceDirPath -Recurse + foreach($currentContent in $dirContents) + { + $isContainer = $currentContent -is [System.IO.DirectoryInfo] + if(!$isContainer) + { + $subDirFiles.Add($currentContent.FullName) + } + else + { + # The currentContent points to a directory. + # We need to check if the directory is an empty directory, if so such a + # directory has to be explicitly added to the archive file. + # if there are no files in the directory the GetFiles() API returns an empty array. + $files = $currentContent.GetFiles() + if($files.Count -eq 0) + { + $subDirFiles.Add($currentContent.FullName + [System.IO.Path]::DirectorySeparatorChar) + } + } + } + + $numberOfItemsArchived = ZipArchiveHelper $subDirFiles.ToArray() $destinationPath $compressionLevel $isUpdateMode $modifiedSourceDirFullName $previousSegmentWeight $currentSegmentWeight + + return $numberOfItemsArchived +} + +function ZipArchiveHelper +{ + param + ( + [System.Collections.Generic.List[System.String]] $sourcePaths, + [string] $destinationPath, + [string] $compressionLevel, + [bool] $isUpdateMode, + [string] $modifiedSourceDirFullName, + [double] $previousSegmentWeight, + [double] $currentSegmentWeight + ) + + $numberOfItemsArchived = 0 + $fileMode = [System.IO.FileMode]::Create + $result = Test-Path -LiteralPath $DestinationPath -Type Leaf + if($result -eq $true) + { + $fileMode = [System.IO.FileMode]::Open + } + + Add-CompressionAssemblies + + try + { + # At this point we are sure that the archive file has write access. + $archiveFileStreamArgs = @($destinationPath, $fileMode) + $archiveFileStream = New-Object -TypeName System.IO.FileStream -ArgumentList $archiveFileStreamArgs + + $zipArchiveArgs = @($archiveFileStream, [System.IO.Compression.ZipArchiveMode]::Update, $false) + $zipArchive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList $zipArchiveArgs + + $currentEntryCount = 0 + $progressBarStatus = ($LocalizedData.CompressProgressBarText -f $destinationPath) + $bufferSize = 4kb + $buffer = New-Object Byte[] $bufferSize + + foreach($currentFilePath in $sourcePaths) + { + if($modifiedSourceDirFullName -ne $null -and $modifiedSourceDirFullName.Length -gt 0) + { + $index = $currentFilePath.IndexOf($modifiedSourceDirFullName, [System.StringComparison]::OrdinalIgnoreCase) + $currentFilePathSubString = $currentFilePath.Substring($index, $modifiedSourceDirFullName.Length) + $relativeFilePath = $currentFilePath.Replace($currentFilePathSubString, "").Trim() + } + else + { + $relativeFilePath = [System.IO.Path]::GetFileName($currentFilePath) + } + + # Update mode is selected. + # Check to see if archive file already contains one or more zip files in it. + if($isUpdateMode -eq $true -and $zipArchive.Entries.Count -gt 0) + { + $entryToBeUpdated = $null + + # Check if the file already exists in the archive file. + # If so replace it with new file from the input source. + # If the file does not exist in the archive file then default to + # create mode and create the entry in the archive file. + + foreach($currentArchiveEntry in $zipArchive.Entries) + { + if(ArchivePathCompareHelper $currentArchiveEntry.FullName $relativeFilePath) + { + $entryToBeUpdated = $currentArchiveEntry + break + } + } + + if($entryToBeUpdated -ne $null) + { + $addItemtoArchiveFileMessage = ($LocalizedData.AddItemtoArchiveFile -f $currentFilePath) + $entryToBeUpdated.Delete() + } + } + + $compression = CompressionLevelMapper $compressionLevel + + # If a directory needs to be added to an archive file, + # by convention the .Net API's expect the path of the directory + # to end with directory separator to detect the path as an directory. + if(!$relativeFilePath.EndsWith([System.IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) + { + try + { + try + { + $currentFileStream = [System.IO.File]::Open($currentFilePath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::Read) + } + catch + { + # Failed to access the file. Write a non terminating error to the pipeline + # and move on with the remaining files. + $exception = $_.Exception + if($null -ne $_.Exception -and + $null -ne $_.Exception.InnerException) + { + $exception = $_.Exception.InnerException + } + $errorRecord = CreateErrorRecordHelper "CompressArchiveUnauthorizedAccessError" $null ([System.Management.Automation.ErrorCategory]::PermissionDenied) $exception $currentFilePath + Write-Error -ErrorRecord $errorRecord + } + + if($null -ne $currentFileStream) + { + $srcStream = New-Object System.IO.BinaryReader $currentFileStream + + $entryPath = DirectorySeparatorNormalizeHelper $relativeFilePath + $currentArchiveEntry = $zipArchive.CreateEntry($entryPath, $compression) + + # Updating the File Creation time so that the same timestamp would be retained after expanding the compressed file. + # At this point we are sure that Get-ChildItem would succeed. + $lastWriteTime = (Get-Item -LiteralPath $currentFilePath).LastWriteTime + if ($lastWriteTime.Year -lt 1980) + { + Write-Warning "'$currentFilePath' has LastWriteTime earlier than 1980. Compress-Archive will store any files with LastWriteTime values earlier than 1980 as 1/1/1980 00:00." + $lastWriteTime = [DateTime]::Parse('1980-01-01T00:00:00') + } + + $currentArchiveEntry.LastWriteTime = $lastWriteTime + + $destStream = New-Object System.IO.BinaryWriter $currentArchiveEntry.Open() + + while($numberOfBytesRead = $srcStream.Read($buffer, 0, $bufferSize)) + { + $destStream.Write($buffer, 0, $numberOfBytesRead) + $destStream.Flush() + } + + $numberOfItemsArchived += 1 + $addItemtoArchiveFileMessage = ($LocalizedData.AddItemtoArchiveFile -f $currentFilePath) + } + } + finally + { + If($null -ne $currentFileStream) + { + $currentFileStream.Dispose() + } + If($null -ne $srcStream) + { + $srcStream.Dispose() + } + If($null -ne $destStream) + { + $destStream.Dispose() + } + } + } + else + { + $entryPath = DirectorySeparatorNormalizeHelper $relativeFilePath + $currentArchiveEntry = $zipArchive.CreateEntry($entryPath, $compression) + $numberOfItemsArchived += 1 + $addItemtoArchiveFileMessage = ($LocalizedData.AddItemtoArchiveFile -f $currentFilePath) + } + + if($null -ne $addItemtoArchiveFileMessage) + { + Write-Verbose $addItemtoArchiveFileMessage + } + + $currentEntryCount += 1 + ProgressBarHelper "Compress-Archive" $progressBarStatus $previousSegmentWeight $currentSegmentWeight $sourcePaths.Count $currentEntryCount + } + } + finally + { + If($null -ne $zipArchive) + { + $zipArchive.Dispose() + } + + If($null -ne $archiveFileStream) + { + $archiveFileStream.Dispose() + } + + # Complete writing progress. + Write-Progress -Activity "Compress-Archive" -Completed + } + + return $numberOfItemsArchived +} + +<############################################################################################ +# ValidateArchivePathHelper: This is a helper function used to validate the archive file +# path & its file format. The only supported archive file format is .zip +############################################################################################> +function ValidateArchivePathHelper +{ + param + ( + [string] $archiveFile + ) + + if(-not [System.IO.File]::Exists($archiveFile)) + { + $errorMessage = ($LocalizedData.PathNotFoundError -f $archiveFile) + ThrowTerminatingErrorHelper "PathNotFound" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidArgument) $archiveFile + } +} + +<############################################################################################ +# ExpandArchiveHelper: This is a helper function used to expand the archive file contents +# to the specified directory. +############################################################################################> +function ExpandArchiveHelper +{ + param + ( + [string] $archiveFile, + [string] $expandedDir, + [ref] $expandedItems, + [boolean] $force, + [boolean] $isVerbose, + [boolean] $isConfirm + ) + + Add-CompressionAssemblies + + try + { + # The existence of archive file has already been validated by ValidateArchivePathHelper + # before calling this helper function. + $archiveFileStreamArgs = @($archiveFile, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read) + $archiveFileStream = New-Object -TypeName System.IO.FileStream -ArgumentList $archiveFileStreamArgs + + $zipArchiveArgs = @($archiveFileStream, [System.IO.Compression.ZipArchiveMode]::Read, $false) + try + { + $zipArchive = New-Object -TypeName System.IO.Compression.ZipArchive -ArgumentList $zipArchiveArgs + } + catch [System.IO.InvalidDataException] + { + # Failed to open the file for reading as a zip archive. Wrap the exception + # and re-throw it indicating it does not appear to be a valid zip file. + $exception = $_.Exception + if($null -ne $_.Exception -and + $null -ne $_.Exception.InnerException) + { + $exception = $_.Exception.InnerException + } + # Load the WindowsBase.dll assembly to get access to the System.IO.FileFormatException class + [System.Reflection.Assembly]::Load('WindowsBase,Version=4.0.0.0,Culture=neutral,PublicKeyToken=31bf3856ad364e35') + $invalidFileFormatException = New-Object -TypeName System.IO.FileFormatException -ArgumentList @( + ($LocalizedData.ItemDoesNotAppearToBeAValidZipArchive -f $archiveFile) + $exception + ) + throw $invalidFileFormatException + } + + if($zipArchive.Entries.Count -eq 0) + { + $archiveFileIsEmpty = ($LocalizedData.ArchiveFileIsEmpty -f $archiveFile) + Write-Verbose $archiveFileIsEmpty + return + } + + $currentEntryCount = 0 + $progressBarStatus = ($LocalizedData.ExpandProgressBarText -f $archiveFile) + + # Ensures that the last character on the extraction path is the directory separator char. + # Without this, a bad zip file could try to traverse outside of the expected extraction path. + # At this point $expandedDir is a fully qualified path without any relative segments. + if (-not $expandedDir.EndsWith([System.IO.Path]::DirectorySeparatorChar)) + { + $expandedDir += [System.IO.Path]::DirectorySeparatorChar + } + + # The archive entries can either be empty directories or files. + foreach($currentArchiveEntry in $zipArchive.Entries) + { + # Windows filesystem provider will internally convert from `/` to `\` + $currentArchiveEntryPath = Join-Path -Path $expandedDir -ChildPath $currentArchiveEntry.FullName + + # Remove possible relative segments from target + # This is similar to [System.IO.Path]::GetFullPath($currentArchiveEntryPath) but uses PS current dir instead of process-wide current dir + $currentArchiveEntryPath = $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($currentArchiveEntryPath) + + # Check that expanded relative paths and absolute paths from the archive are Not going outside of target directory + # Ordinal match is safest, case-sensitive volumes can be mounted within volumes that are case-insensitive. + if (-not ($currentArchiveEntryPath.StartsWith($expandedDir, [System.StringComparison]::Ordinal))) + { + $BadArchiveEntryMessage = ($LocalizedData.BadArchiveEntry -f $currentArchiveEntry.FullName) + # notify user of bad archive entry + Write-Error $BadArchiveEntryMessage + # move on to the next entry in the archive + continue + } + + $extension = [system.IO.Path]::GetExtension($currentArchiveEntryPath) + + # The current archive entry is an empty directory + # The FullName of the Archive Entry representing a directory would end with a trailing directory separator. + if($extension -eq [string]::Empty -and + $currentArchiveEntryPath.EndsWith([System.IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) + { + $pathExists = Test-Path -LiteralPath $currentArchiveEntryPath + + # The current archive entry expects an empty directory. + # Check if the existing directory is empty. If it's not empty + # then it means that user has added this directory by other means. + if($pathExists -eq $false) + { + New-Item $currentArchiveEntryPath -Type Directory -Confirm:$isConfirm | Out-Null + + if(Test-Path -LiteralPath $currentArchiveEntryPath -PathType Container) + { + $addEmptyDirectorytoExpandedPathMessage = ($LocalizedData.AddItemtoArchiveFile -f $currentArchiveEntryPath) + Write-Verbose $addEmptyDirectorytoExpandedPathMessage + + $expandedItems.Value += $currentArchiveEntryPath + } + } + } + else + { + try + { + $currentArchiveEntryFileInfo = New-Object -TypeName System.IO.FileInfo -ArgumentList $currentArchiveEntryPath + $parentDirExists = Test-Path -LiteralPath $currentArchiveEntryFileInfo.DirectoryName -PathType Container + + # If the Parent directory of the current entry in the archive file does not exist, then create it. + if($parentDirExists -eq $false) + { + # note that if any ancestor of this directory doesn't exist, we don't recursively create each one as New-Item + # takes care of this already, so only one DirectoryInfo is returned instead of one for each parent directory + # that only contains directories + New-Item $currentArchiveEntryFileInfo.DirectoryName -Type Directory -Confirm:$isConfirm | Out-Null + + if(!(Test-Path -LiteralPath $currentArchiveEntryFileInfo.DirectoryName -PathType Container)) + { + # The directory referred by $currentArchiveEntryFileInfo.DirectoryName was not successfully created. + # This could be because the user has specified -Confirm parameter when Expand-Archive was invoked + # and authorization was not provided when confirmation was prompted. In such a scenario, + # we skip the current file in the archive and continue with the remaining archive file contents. + Continue + } + + $expandedItems.Value += $currentArchiveEntryFileInfo.DirectoryName + } + + $hasNonTerminatingError = $false + + # Check if the file in to which the current archive entry contents + # would be expanded already exists. + if($currentArchiveEntryFileInfo.Exists) + { + if($force) + { + Remove-Item -LiteralPath $currentArchiveEntryFileInfo.FullName -Force -ErrorVariable ev -Verbose:$isVerbose -Confirm:$isConfirm + if($ev -ne $null) + { + $hasNonTerminatingError = $true + } + + if(Test-Path -LiteralPath $currentArchiveEntryFileInfo.FullName -PathType Leaf) + { + # The file referred by $currentArchiveEntryFileInfo.FullName was not successfully removed. + # This could be because the user has specified -Confirm parameter when Expand-Archive was invoked + # and authorization was not provided when confirmation was prompted. In such a scenario, + # we skip the current file in the archive and continue with the remaining archive file contents. + Continue + } + } + else + { + # Write non-terminating error to the pipeline. + $errorMessage = ($LocalizedData.FileExistsError -f $currentArchiveEntryFileInfo.FullName, $archiveFile, $currentArchiveEntryFileInfo.FullName, $currentArchiveEntryFileInfo.FullName) + $errorRecord = CreateErrorRecordHelper "ExpandArchiveFileExists" $errorMessage ([System.Management.Automation.ErrorCategory]::InvalidOperation) $null $currentArchiveEntryFileInfo.FullName + Write-Error -ErrorRecord $errorRecord + $hasNonTerminatingError = $true + } + } + + if(!$hasNonTerminatingError) + { + # The ExtractToFile() method doesn't handle whitespace correctly, strip whitespace which is consistent with how Explorer handles archives + # There is an edge case where an archive contains files whose only difference is whitespace, but this is uncommon and likely not legitimate + [string[]] $parts = $currentArchiveEntryPath.Split([System.IO.Path]::DirectorySeparatorChar) | % { $_.Trim() } + $currentArchiveEntryPath = [string]::Join([System.IO.Path]::DirectorySeparatorChar, $parts) + + [System.IO.Compression.ZipFileExtensions]::ExtractToFile($currentArchiveEntry, $currentArchiveEntryPath, $false) + + # Add the expanded file path to the $expandedItems array, + # to keep track of all the expanded files created while expanding the archive file. + # If user enters CTRL + C then at that point of time, all these expanded files + # would be deleted as part of the clean up process. + $expandedItems.Value += $currentArchiveEntryPath + + $addFiletoExpandedPathMessage = ($LocalizedData.CreateFileAtExpandedPath -f $currentArchiveEntryPath) + Write-Verbose $addFiletoExpandedPathMessage + } + } + finally + { + If($null -ne $destStream) + { + $destStream.Dispose() + } + + If($null -ne $srcStream) + { + $srcStream.Dispose() + } + } + } + + $currentEntryCount += 1 + # $currentSegmentWeight is Set to 100 giving equal weightage to each file that is getting expanded. + # $previousSegmentWeight is set to 0 as there are no prior segments. + $previousSegmentWeight = 0 + $currentSegmentWeight = 100 + ProgressBarHelper "Expand-Archive" $progressBarStatus $previousSegmentWeight $currentSegmentWeight $zipArchive.Entries.Count $currentEntryCount + } + } + finally + { + If($null -ne $zipArchive) + { + $zipArchive.Dispose() + } + + If($null -ne $archiveFileStream) + { + $archiveFileStream.Dispose() + } + + # Complete writing progress. + Write-Progress -Activity "Expand-Archive" -Completed + } +} + +<############################################################################################ +# ProgressBarHelper: This is a helper function used to display progress message. +# This function is used by both Compress-Archive & Expand-Archive to display archive file +# creation/expansion progress. +############################################################################################> +function ProgressBarHelper +{ + param + ( + [string] $cmdletName, + [string] $status, + [double] $previousSegmentWeight, + [double] $currentSegmentWeight, + [int] $totalNumberofEntries, + [int] $currentEntryCount + ) + + if($currentEntryCount -gt 0 -and + $totalNumberofEntries -gt 0 -and + $previousSegmentWeight -ge 0 -and + $currentSegmentWeight -gt 0) + { + $entryDefaultWeight = $currentSegmentWeight/[double]$totalNumberofEntries + + $percentComplete = $previousSegmentWeight + ($entryDefaultWeight * $currentEntryCount) + Write-Progress -Activity $cmdletName -Status $status -PercentComplete $percentComplete + } +} + +<############################################################################################ +# CSVHelper: This is a helper function used to append comma after each path specified by +# the SourcePath array. This helper function is used to display all the user supplied paths +# in the WhatIf message. +############################################################################################> +function CSVHelper +{ + param + ( + [string[]] $sourcePath + ) + + # SourcePath has already been validated by the calling function. + if($sourcePath.Count -gt 1) + { + $sourcePathInCsvFormat = "`n" + for($currentIndex=0; $currentIndex -lt $sourcePath.Count; $currentIndex++) + { + if($currentIndex -eq $sourcePath.Count - 1) + { + $sourcePathInCsvFormat += $sourcePath[$currentIndex] + } + else + { + $sourcePathInCsvFormat += $sourcePath[$currentIndex] + "`n" + } + } + } + else + { + $sourcePathInCsvFormat = $sourcePath + } + + return $sourcePathInCsvFormat +} + +<############################################################################################ +# ThrowTerminatingErrorHelper: This is a helper function used to throw terminating error. +############################################################################################> +function ThrowTerminatingErrorHelper +{ + param + ( + [string] $errorId, + [string] $errorMessage, + [System.Management.Automation.ErrorCategory] $errorCategory, + [object] $targetObject, + [Exception] $innerException + ) + + if($innerException -eq $null) + { + $exception = New-object System.IO.IOException $errorMessage + } + else + { + $exception = New-Object System.IO.IOException $errorMessage, $innerException + } + + $exception = New-Object System.IO.IOException $errorMessage + $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception, $errorId, $errorCategory, $targetObject + $PSCmdlet.ThrowTerminatingError($errorRecord) +} + +<############################################################################################ +# CreateErrorRecordHelper: This is a helper function used to create an ErrorRecord +############################################################################################> +function CreateErrorRecordHelper +{ + param + ( + [string] $errorId, + [string] $errorMessage, + [System.Management.Automation.ErrorCategory] $errorCategory, + [Exception] $exception, + [object] $targetObject + ) + + if($null -eq $exception) + { + $exception = New-Object System.IO.IOException $errorMessage + } + + $errorRecord = New-Object System.Management.Automation.ErrorRecord $exception, $errorId, $errorCategory, $targetObject + return $errorRecord +} + +<############################################################################################ +# DirectorySeparatorNormalizeHelper: This is a helper function used to normalize separators +# when compressing archives, creating cross platform archives. +# +# The approach taken is leveraging the fact that .net on Windows all the way back to +# Framework 1.1 specifies `\` as DirectoryPathSeparatorChar and `/` as +# AltDirectoryPathSeparatorChar, while other platforms in .net Core use `/` for +# DirectoryPathSeparatorChar and AltDirectoryPathSeparatorChar. When using a *nix platform, +# the replacements will be no-ops, while Windows will convert all `\` to `/` for the +# purposes of the ZipEntry FullName. +############################################################################################> +function DirectorySeparatorNormalizeHelper +{ + param + ( + [string] $archivePath + ) + + if($null -eq $archivePath) + { + return $archivePath + } + + return $archivePath.replace([System.IO.Path]::DirectorySeparatorChar, [System.IO.Path]::AltDirectorySeparatorChar) +} + +<############################################################################################ +# ArchivePathCompareHelper: This is a helper function used to compare with normalized +# separators. +############################################################################################> +function ArchivePathCompareHelper +{ + param + ( + [string] $pathArgA, + [string] $pathArgB + ) + + $normalizedPathArgA = DirectorySeparatorNormalizeHelper $pathArgA + $normalizedPathArgB = DirectorySeparatorNormalizeHelper $pathArgB + + return $normalizedPathArgA -eq $normalizedPathArgB +} + +# SIG # Begin signature block +# MIIjigYJKoZIhvcNAQcCoIIjezCCI3cCAQExDzANBglghkgBZQMEAgEFADB5Bgor +# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG +# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCCxWUVaNUPabUNe +# T6XgWNeKW3CfCVc/X+PBlTJ8w9y52qCCDYUwggYDMIID66ADAgECAhMzAAABUptA +# n1BWmXWIAAAAAAFSMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD +# VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy +# b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p +# bmcgUENBIDIwMTEwHhcNMTkwNTAyMjEzNzQ2WhcNMjAwNTAyMjEzNzQ2WjB0MQsw +# CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u +# ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy +# b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +# AQCxp4nT9qfu9O10iJyewYXHlN+WEh79Noor9nhM6enUNbCbhX9vS+8c/3eIVazS +# YnVBTqLzW7xWN1bCcItDbsEzKEE2BswSun7J9xCaLwcGHKFr+qWUlz7hh9RcmjYS +# kOGNybOfrgj3sm0DStoK8ljwEyUVeRfMHx9E/7Ca/OEq2cXBT3L0fVnlEkfal310 +# EFCLDo2BrE35NGRjG+/nnZiqKqEh5lWNk33JV8/I0fIcUKrLEmUGrv0CgC7w2cjm +# bBhBIJ+0KzSnSWingXol/3iUdBBy4QQNH767kYGunJeY08RjHMIgjJCdAoEM+2mX +# v1phaV7j+M3dNzZ/cdsz3oDfAgMBAAGjggGCMIIBfjAfBgNVHSUEGDAWBgorBgEE +# AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQU3f8Aw1sW72WcJ2bo/QSYGzVrRYcw +# VAYDVR0RBE0wS6RJMEcxLTArBgNVBAsTJE1pY3Jvc29mdCBJcmVsYW5kIE9wZXJh +# dGlvbnMgTGltaXRlZDEWMBQGA1UEBRMNMjMwMDEyKzQ1NDEzNjAfBgNVHSMEGDAW +# gBRIbmTlUAXTgqoXNzcitW2oynUClTBUBgNVHR8ETTBLMEmgR6BFhkNodHRwOi8v +# d3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NybC9NaWNDb2RTaWdQQ0EyMDExXzIw +# MTEtMDctMDguY3JsMGEGCCsGAQUFBwEBBFUwUzBRBggrBgEFBQcwAoZFaHR0cDov +# L3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jZXJ0cy9NaWNDb2RTaWdQQ0EyMDEx +# XzIwMTEtMDctMDguY3J0MAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggIB +# AJTwROaHvogXgixWjyjvLfiRgqI2QK8GoG23eqAgNjX7V/WdUWBbs0aIC3k49cd0 +# zdq+JJImixcX6UOTpz2LZPFSh23l0/Mo35wG7JXUxgO0U+5drbQht5xoMl1n7/TQ +# 4iKcmAYSAPxTq5lFnoV2+fAeljVA7O43szjs7LR09D0wFHwzZco/iE8Hlakl23ZT +# 7FnB5AfU2hwfv87y3q3a5qFiugSykILpK0/vqnlEVB0KAdQVzYULQ/U4eFEjnis3 +# Js9UrAvtIhIs26445Rj3UP6U4GgOjgQonlRA+mDlsh78wFSGbASIvK+fkONUhvj8 +# B8ZHNn4TFfnct+a0ZueY4f6aRPxr8beNSUKn7QW/FQmn422bE7KfnqWncsH7vbNh +# G929prVHPsaa7J22i9wyHj7m0oATXJ+YjfyoEAtd5/NyIYaE4Uu0j1EhuYUo5VaJ +# JnMaTER0qX8+/YZRWrFN/heps41XNVjiAawpbAa0fUa3R9RNBjPiBnM0gvNPorM4 +# dsV2VJ8GluIQOrJlOvuCrOYDGirGnadOmQ21wPBoGFCWpK56PxzliKsy5NNmAXcE +# x7Qb9vUjY1WlYtrdwOXTpxN4slzIht69BaZlLIjLVWwqIfuNrhHKNDM9K+v7vgrI +# bf7l5/665g0gjQCDCN6Q5sxuttTAEKtJeS/pkpI+DbZ/MIIHejCCBWKgAwIBAgIK +# YQ6Q0gAAAAAAAzANBgkqhkiG9w0BAQsFADCBiDELMAkGA1UEBhMCVVMxEzARBgNV +# BAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jv +# c29mdCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlm +# aWNhdGUgQXV0aG9yaXR5IDIwMTEwHhcNMTEwNzA4MjA1OTA5WhcNMjYwNzA4MjEw +# OTA5WjB+MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE +# BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSgwJgYD +# VQQDEx9NaWNyb3NvZnQgQ29kZSBTaWduaW5nIFBDQSAyMDExMIICIjANBgkqhkiG +# 9w0BAQEFAAOCAg8AMIICCgKCAgEAq/D6chAcLq3YbqqCEE00uvK2WCGfQhsqa+la +# UKq4BjgaBEm6f8MMHt03a8YS2AvwOMKZBrDIOdUBFDFC04kNeWSHfpRgJGyvnkmc +# 6Whe0t+bU7IKLMOv2akrrnoJr9eWWcpgGgXpZnboMlImEi/nqwhQz7NEt13YxC4D +# dato88tt8zpcoRb0RrrgOGSsbmQ1eKagYw8t00CT+OPeBw3VXHmlSSnnDb6gE3e+ +# lD3v++MrWhAfTVYoonpy4BI6t0le2O3tQ5GD2Xuye4Yb2T6xjF3oiU+EGvKhL1nk +# kDstrjNYxbc+/jLTswM9sbKvkjh+0p2ALPVOVpEhNSXDOW5kf1O6nA+tGSOEy/S6 +# A4aN91/w0FK/jJSHvMAhdCVfGCi2zCcoOCWYOUo2z3yxkq4cI6epZuxhH2rhKEmd +# X4jiJV3TIUs+UsS1Vz8kA/DRelsv1SPjcF0PUUZ3s/gA4bysAoJf28AVs70b1FVL +# 5zmhD+kjSbwYuER8ReTBw3J64HLnJN+/RpnF78IcV9uDjexNSTCnq47f7Fufr/zd +# sGbiwZeBe+3W7UvnSSmnEyimp31ngOaKYnhfsi+E11ecXL93KCjx7W3DKI8sj0A3 +# T8HhhUSJxAlMxdSlQy90lfdu+HggWCwTXWCVmj5PM4TasIgX3p5O9JawvEagbJjS +# 4NaIjAsCAwEAAaOCAe0wggHpMBAGCSsGAQQBgjcVAQQDAgEAMB0GA1UdDgQWBBRI +# bmTlUAXTgqoXNzcitW2oynUClTAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTAL +# BgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBRyLToCMZBD +# uRQFTuHqp8cx0SOJNDBaBgNVHR8EUzBRME+gTaBLhklodHRwOi8vY3JsLm1pY3Jv +# c29mdC5jb20vcGtpL2NybC9wcm9kdWN0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFf +# MDNfMjIuY3JsMF4GCCsGAQUFBwEBBFIwUDBOBggrBgEFBQcwAoZCaHR0cDovL3d3 +# dy5taWNyb3NvZnQuY29tL3BraS9jZXJ0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFf +# MDNfMjIuY3J0MIGfBgNVHSAEgZcwgZQwgZEGCSsGAQQBgjcuAzCBgzA/BggrBgEF +# BQcCARYzaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9kb2NzL3ByaW1h +# cnljcHMuaHRtMEAGCCsGAQUFBwICMDQeMiAdAEwAZQBnAGEAbABfAHAAbwBsAGkA +# YwB5AF8AcwB0AGEAdABlAG0AZQBuAHQALiAdMA0GCSqGSIb3DQEBCwUAA4ICAQBn +# 8oalmOBUeRou09h0ZyKbC5YR4WOSmUKWfdJ5DJDBZV8uLD74w3LRbYP+vj/oCso7 +# v0epo/Np22O/IjWll11lhJB9i0ZQVdgMknzSGksc8zxCi1LQsP1r4z4HLimb5j0b +# pdS1HXeUOeLpZMlEPXh6I/MTfaaQdION9MsmAkYqwooQu6SpBQyb7Wj6aC6VoCo/ +# KmtYSWMfCWluWpiW5IP0wI/zRive/DvQvTXvbiWu5a8n7dDd8w6vmSiXmE0OPQvy +# CInWH8MyGOLwxS3OW560STkKxgrCxq2u5bLZ2xWIUUVYODJxJxp/sfQn+N4sOiBp +# mLJZiWhub6e3dMNABQamASooPoI/E01mC8CzTfXhj38cbxV9Rad25UAqZaPDXVJi +# hsMdYzaXht/a8/jyFqGaJ+HNpZfQ7l1jQeNbB5yHPgZ3BtEGsXUfFL5hYbXw3MYb +# BL7fQccOKO7eZS/sl/ahXJbYANahRr1Z85elCUtIEJmAH9AAKcWxm6U/RXceNcbS +# oqKfenoi+kiVH6v7RyOA9Z74v2u3S5fi63V4GuzqN5l5GEv/1rMjaHXmr/r8i+sL +# gOppO6/8MO0ETI7f33VtY5E90Z1WTk+/gFcioXgRMiF670EKsT/7qMykXcGhiJtX +# cVZOSEXAQsmbdlsKgEhr/Xmfwb1tbWrJUnMTDXpQzTGCFVswghVXAgEBMIGVMH4x +# CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRt +# b25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01p +# Y3Jvc29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMTECEzMAAAFSm0CfUFaZdYgAAAAA +# AVIwDQYJYIZIAWUDBAIBBQCgga4wGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQw +# HAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIDtq +# icg7O3t+8aEthrg0b2qP4aWs9LkzLpmfTq0zfyrgMEIGCisGAQQBgjcCAQwxNDAy +# oBSAEgBNAGkAYwByAG8AcwBvAGYAdKEagBhodHRwOi8vd3d3Lm1pY3Jvc29mdC5j +# b20wDQYJKoZIhvcNAQEBBQAEggEAEc8jC370rcVzKGJGuP/id6ETZxvJvR51hEBc +# wIQIV6RfjKKR4eH9yHGDHhx1uWvdzkiYAApbviY1ISYpLsMz1/ugN0QTcBJt2PGr +# 8abeBLzUH6whhzY8xVnk3CGox9DkgS72NuQ8GkzwbXXwZ/EgNNT8j9JtqC/Ivnbh +# ciuIAwAzymIMIlJ7jZm1GFuOqUDkpQ2DZ6BloNgA5CjE1BImmZjEKI5dNsJd+jZ2 +# CcpIRPlG7GYD2GlaR8Aw/lfkgIQB22P0eR+hm8Qa2vbqpIZQZV63BBPyZnahBllb +# wocwG9/tVlhoe0HD2Iv1nU9PC+K8s84i5MdYNIEnrsTC3AlJDaGCEuUwghLhBgor +# BgEEAYI3AwMBMYIS0TCCEs0GCSqGSIb3DQEHAqCCEr4wghK6AgEDMQ8wDQYJYIZI +# AWUDBAIBBQAwggFRBgsqhkiG9w0BCRABBKCCAUAEggE8MIIBOAIBAQYKKwYBBAGE +# WQoDATAxMA0GCWCGSAFlAwQCAQUABCDV4w1fw0ZxcYyjngmpg8RKkuUEKE9srNko +# UiQpktfOpwIGXin1JLlvGBMyMDIwMDIxMTE5MzgwOC4zNTFaMASAAgH0oIHQpIHN +# MIHKMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMH +# UmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQL +# ExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMSYwJAYDVQQLEx1UaGFsZXMg +# VFNTIEVTTjozQkJELUUzMzgtRTlBMTElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUt +# U3RhbXAgU2VydmljZaCCDjwwggTxMIID2aADAgECAhMzAAABHcLCCK4+uq5IAAAA +# AAEdMA0GCSqGSIb3DQEBCwUAMHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNo +# aW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29y +# cG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEw +# MB4XDTE5MTExMzIxNDAzOVoXDTIxMDIxMTIxNDAzOVowgcoxCzAJBgNVBAYTAlVT +# MRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQK +# ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJTAjBgNVBAsTHE1pY3Jvc29mdCBBbWVy +# aWNhIE9wZXJhdGlvbnMxJjAkBgNVBAsTHVRoYWxlcyBUU1MgRVNOOjNCQkQtRTMz +# OC1FOUExMSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNlMIIB +# IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAnIUB+qF+9ZgKHPlQgCkog4Jx +# E7nDBGiccHUtx3G4Dmf7Sq1xPxbYhj47XjszH4ko6Hpd9FogVJLRMdXOs/+At1zC +# Gh1jY+/yEeg/We+4torptCryEChrH5hfWuijIINh1jOESX852lVrR/U+SryLRdrB +# ygw3Hjv3O4VAGiMy6lQjTBNq+la+5GHyJ7uTRx9KpIDiBmVvQVYJraXqVmaErl3B +# phfx9THN9jfyr/LF1f4WJRN004AswPLObTaL7KKYcw/V9AHLLDbCdKkamwO7v7K9 +# yWbUGs4z7Y38NcBr4dVDCd5XJq4GncJZZUcNtSs49VzRLvjWKfIszyO6r6D85QID +# AQABo4IBGzCCARcwHQYDVR0OBBYEFBdeWhRg3sjyCL4rQ2oIz8Ctm8KpMB8GA1Ud +# IwQYMBaAFNVjOlyKMZDzQ3t8RhvFM2hahW1VMFYGA1UdHwRPME0wS6BJoEeGRWh0 +# dHA6Ly9jcmwubWljcm9zb2Z0LmNvbS9wa2kvY3JsL3Byb2R1Y3RzL01pY1RpbVN0 +# YVBDQV8yMDEwLTA3LTAxLmNybDBaBggrBgEFBQcBAQROMEwwSgYIKwYBBQUHMAKG +# Pmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljVGltU3RhUENB +# XzIwMTAtMDctMDEuY3J0MAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYIKwYBBQUH +# AwgwDQYJKoZIhvcNAQELBQADggEBAGCMkZ2Q8AzLOODQFevh+mz54xvfUV8OEXin +# Y9EkjuSHcAhRIzC5Dx38/ZXuVd4QAYWuvPOhDMb90tvcn6VB1uamwrSwp/AsnYmf +# HsjSnBR7iphJsOrMlquFP7Vjh4+ObwBadetdlSN//m2dJ4PNgHTeo54ypbsP8E37 +# arRuiHP7TTWOsbalUfmWXKvKDBJyn2I96L7NoluPC9u8hrVg/ReobiUkSLiGyttz +# NL6IUTns+bZ51ky1C6NXEY/LZmnUy03FTnM4H7t2c2tMxbhQW1H5BGdIDButZya5 +# BZ14pcl4feBYev/SMBRsg6aGB/CdwQFKf1oW3gX60Mk1VhvMSTAwggZxMIIEWaAD +# AgECAgphCYEqAAAAAAACMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJVUzET +# MBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMV +# TWljcm9zb2Z0IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUm9vdCBD +# ZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAxMDAeFw0xMDA3MDEyMTM2NTVaFw0yNTA3 +# MDEyMTQ2NTVaMHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAw +# DgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x +# JjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMIIBIjANBgkq +# hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqR0NvHcRijog7PwTl/X6f2mUa3RUENWl +# CgCChfvtfGhLLF/Fw+Vhwna3PmYrW/AVUycEMR9BGxqVHc4JE458YTBZsTBED/Fg +# iIRUQwzXTbg4CLNC3ZOs1nMwVyaCo0UN0Or1R4HNvyRgMlhgRvJYR4YyhB50YWeR +# X4FUsc+TTJLBxKZd0WETbijGGvmGgLvfYfxGwScdJGcSchohiq9LZIlQYrFd/Xcf +# PfBXday9ikJNQFHRD5wGPmd/9WbAA5ZEfu/QS/1u5ZrKsajyeioKMfDaTgaRtogI +# Neh4HLDpmc085y9Euqf03GS9pAHBIAmTeM38vMDJRF1eFpwBBU8iTQIDAQABo4IB +# 5jCCAeIwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0OBBYEFNVjOlyKMZDzQ3t8RhvF +# M2hahW1VMBkGCSsGAQQBgjcUAgQMHgoAUwB1AGIAQwBBMAsGA1UdDwQEAwIBhjAP +# BgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNX2VsuP6KJcYmjRPZSQW9fOmhjE +# MFYGA1UdHwRPME0wS6BJoEeGRWh0dHA6Ly9jcmwubWljcm9zb2Z0LmNvbS9wa2kv +# Y3JsL3Byb2R1Y3RzL01pY1Jvb0NlckF1dF8yMDEwLTA2LTIzLmNybDBaBggrBgEF +# BQcBAQROMEwwSgYIKwYBBQUHMAKGPmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9w +# a2kvY2VydHMvTWljUm9vQ2VyQXV0XzIwMTAtMDYtMjMuY3J0MIGgBgNVHSABAf8E +# gZUwgZIwgY8GCSsGAQQBgjcuAzCBgTA9BggrBgEFBQcCARYxaHR0cDovL3d3dy5t +# aWNyb3NvZnQuY29tL1BLSS9kb2NzL0NQUy9kZWZhdWx0Lmh0bTBABggrBgEFBQcC +# AjA0HjIgHQBMAGUAZwBhAGwAXwBQAG8AbABpAGMAeQBfAFMAdABhAHQAZQBtAGUA +# bgB0AC4gHTANBgkqhkiG9w0BAQsFAAOCAgEAB+aIUQ3ixuCYP4FxAz2do6Ehb7Pr +# psz1Mb7PBeKp/vpXbRkws8LFZslq3/Xn8Hi9x6ieJeP5vO1rVFcIK1GCRBL7uVOM +# zPRgEop2zEBAQZvcXBf/XPleFzWYJFZLdO9CEMivv3/Gf/I3fVo/HPKZeUqRUgCv +# OA8X9S95gWXZqbVr5MfO9sp6AG9LMEQkIjzP7QOllo9ZKby2/QThcJ8ySif9Va8v +# /rbljjO7Yl+a21dA6fHOmWaQjP9qYn/dxUoLkSbiOewZSnFjnXshbcOco6I8+n99 +# lmqQeKZt0uGc+R38ONiU9MalCpaGpL2eGq4EQoO4tYCbIjggtSXlZOz39L9+Y1kl +# D3ouOVd2onGqBooPiRa6YacRy5rYDkeagMXQzafQ732D8OE7cQnfXXSYIghh2rBQ +# Hm+98eEA3+cxB6STOvdlR3jo+KhIq/fecn5ha293qYHLpwmsObvsxsvYgrRyzR30 +# uIUBHoD7G4kqVDmyW9rIDVWZeodzOwjmmC3qjeAzLhIp9cAvVCch98isTtoouLGp +# 25ayp0Kiyc8ZQU3ghvkqmqMRZjDTu3QyS99je/WZii8bxyGvWbWu3EQ8l1Bx16HS +# xVXjad5XwdHeMMD9zOZN+w2/XU/pnR4ZOC+8z1gFLu8NoFA12u8JJxzVs341Hgi6 +# 2jbb01+P3nSISRKhggLOMIICNwIBATCB+KGB0KSBzTCByjELMAkGA1UEBhMCVVMx +# EzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoT +# FU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjElMCMGA1UECxMcTWljcm9zb2Z0IEFtZXJp +# Y2EgT3BlcmF0aW9uczEmMCQGA1UECxMdVGhhbGVzIFRTUyBFU046M0JCRC1FMzM4 +# LUU5QTExJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2WiIwoB +# ATAHBgUrDgMCGgMVAEQjn0R34rNHbg1CEPwsfWkeOCdCoIGDMIGApH4wfDELMAkG +# A1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQx +# HjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9z +# b2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAwDQYJKoZIhvcNAQEFBQACBQDh7X/WMCIY +# DzIwMjAwMjEyMDMzMjM4WhgPMjAyMDAyMTMwMzMyMzhaMHcwPQYKKwYBBAGEWQoE +# ATEvMC0wCgIFAOHtf9YCAQAwCgIBAAICGakCAf8wBwIBAAICEbIwCgIFAOHu0VYC +# AQAwNgYKKwYBBAGEWQoEAjEoMCYwDAYKKwYBBAGEWQoDAqAKMAgCAQACAwehIKEK +# MAgCAQACAwGGoDANBgkqhkiG9w0BAQUFAAOBgQB6XYmpXzuCuteL/rDs5cNRFvgq +# QlPdgd5+/kr0rfpqFXBiS0uWnufltczGk0NKNo2R8DwgGc8R/wW5H3CLhxrf7tgZ +# abDCXBYFzfSUPTwiKBP9EdViLTVrRG8VFBz/NW81xJZ1TpzMdMSTUxXAt/Ono8E2 +# G2KbMlUmYOyq6ssgdDGCAw0wggMJAgEBMIGTMHwxCzAJBgNVBAYTAlVTMRMwEQYD +# VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy +# b3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1w +# IFBDQSAyMDEwAhMzAAABHcLCCK4+uq5IAAAAAAEdMA0GCWCGSAFlAwQCAQUAoIIB +# SjAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwLwYJKoZIhvcNAQkEMSIEILUC +# NXKl/YlyVfES+gN1+8nAYRzrfmlKh6H7dQ8DIuDtMIH6BgsqhkiG9w0BCRACLzGB +# 6jCB5zCB5DCBvQQgAr4Mw0Kfd3yHC65s6QntiApCRe1xG1Je3XJG6WXvmpMwgZgw +# gYCkfjB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE +# BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYD +# VQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMAITMwAAAR3CwgiuPrqu +# SAAAAAABHTAiBCAFMqgDqqAtxrnQVE6L2luJBmrcNX+9s4uG8l2uA3wqKzANBgkq +# hkiG9w0BAQsFAASCAQAXtxamjeAHGlwUi4QIqORGyfoIKKnfnqwfbTNYDXO2pXCS +# UNgXF206NMkAc884mArjjzx6aJjFS6N9a1KOcxiPwK+3TnCBFHaeejGltrw5iSFb +# x7iwyblbL8j4tBl8oD9ZzmdiNLJqhJWtrAchsw2TMgjYu3iyQbr9y+THBxtcXI20 +# dW3mHMPoHFSDT78vrJcAgvoY+Y5WMkOJwLwhcp0uaLPiN7tI1Fh3YBV6LZ/HYHRl +# qkKbpdwz0tuMm75Jt+UhQYjPmQ5pIQWJDQ42fRVn/T7u/A+BL64zGAUQuxdWOP+Y +# ugdowe4KGPFribJHCITEr5SnFnHdamGigpxdyYJb +# SIG # End signature block diff --git a/src/NodePilot.Engine/PowerShell/Modules/Microsoft.PowerShell.Archive/en-US/ArchiveResources.psd1 b/src/NodePilot.Engine/PowerShell/Modules/Microsoft.PowerShell.Archive/en-US/ArchiveResources.psd1 new file mode 100644 index 00000000..6f1388e0 --- /dev/null +++ b/src/NodePilot.Engine/PowerShell/Modules/Microsoft.PowerShell.Archive/en-US/ArchiveResources.psd1 @@ -0,0 +1,219 @@ +# Localized ArchiveResources.psd1 + +ConvertFrom-StringData @' +###PSLOC +PathNotFoundError=The path '{0}' either does not exist or is not a valid file system path. +ExpandArchiveInValidDestinationPath=The path '{0}' is not a valid file system directory path. +InvalidZipFileExtensionError={0} is not a supported archive file format. {1} is the only supported archive file format. +ArchiveFileIsReadOnly=The attributes of the archive file {0} is set to 'ReadOnly' hence it cannot be updated. If you intend to update the existing archive file, remove the 'ReadOnly' attribute on the archive file else use -Force parameter to override and create a new archive file. +ZipFileExistError=The archive file {0} already exists. Use the -Update parameter to update the existing archive file or use the -Force parameter to overwrite the existing archive file. +DuplicatePathFoundError=The input to {0} parameter contains a duplicate path '{1}'. Provide a unique set of paths as input to {2} parameter. +ArchiveFileIsEmpty=The archive file {0} is empty. +CompressProgressBarText=The archive file '{0}' creation is in progress... +ExpandProgressBarText=The archive file '{0}' expansion is in progress... +AppendArchiveFileExtensionMessage=The archive file path '{0}' supplied to the DestinationPath parameter does not include .zip extension. Hence .zip is appended to the supplied DestinationPath path and the archive file would be created at '{1}'. +AddItemtoArchiveFile=Adding '{0}'. +BadArchiveEntry=Can not process invalid archive entry '{0}'. +CreateFileAtExpandedPath=Created '{0}'. +InvalidArchiveFilePathError=The archive file path '{0}' specified as input to the {1} parameter is resolving to multiple file system paths. Provide a unique path to the {2} parameter where the archive file has to be created. +InvalidExpandedDirPathError=The directory path '{0}' specified as input to the DestinationPath parameter is resolving to multiple file system paths. Provide a unique path to the Destination parameter where the archive file contents have to be expanded. +FileExistsError=Failed to create file '{0}' while expanding the archive file '{1}' contents as the file '{2}' already exists. Use the -Force parameter if you want to overwrite the existing directory '{3}' contents when expanding the archive file. +DeleteArchiveFile=The partially created archive file '{0}' is deleted as it is not usable. +InvalidDestinationPath=The destination path '{0}' does not contain a valid archive file name. +PreparingToCompressVerboseMessage=Preparing to compress... +PreparingToExpandVerboseMessage=Preparing to expand... +###PSLOC +'@ + +# SIG # Begin signature block +# MIIjigYJKoZIhvcNAQcCoIIjezCCI3cCAQExDzANBglghkgBZQMEAgEFADB5Bgor +# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG +# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDGjHELqNjhDMSl +# VEIGYXlY2v2AY207IXvPYrihKxZIeaCCDYUwggYDMIID66ADAgECAhMzAAABUptA +# n1BWmXWIAAAAAAFSMA0GCSqGSIb3DQEBCwUAMH4xCzAJBgNVBAYTAlVTMRMwEQYD +# VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy +# b3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01pY3Jvc29mdCBDb2RlIFNpZ25p +# bmcgUENBIDIwMTEwHhcNMTkwNTAyMjEzNzQ2WhcNMjAwNTAyMjEzNzQ2WjB0MQsw +# CQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9u +# ZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMR4wHAYDVQQDExVNaWNy +# b3NvZnQgQ29ycG9yYXRpb24wggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIB +# AQCxp4nT9qfu9O10iJyewYXHlN+WEh79Noor9nhM6enUNbCbhX9vS+8c/3eIVazS +# YnVBTqLzW7xWN1bCcItDbsEzKEE2BswSun7J9xCaLwcGHKFr+qWUlz7hh9RcmjYS +# kOGNybOfrgj3sm0DStoK8ljwEyUVeRfMHx9E/7Ca/OEq2cXBT3L0fVnlEkfal310 +# EFCLDo2BrE35NGRjG+/nnZiqKqEh5lWNk33JV8/I0fIcUKrLEmUGrv0CgC7w2cjm +# bBhBIJ+0KzSnSWingXol/3iUdBBy4QQNH767kYGunJeY08RjHMIgjJCdAoEM+2mX +# v1phaV7j+M3dNzZ/cdsz3oDfAgMBAAGjggGCMIIBfjAfBgNVHSUEGDAWBgorBgEE +# AYI3TAgBBggrBgEFBQcDAzAdBgNVHQ4EFgQU3f8Aw1sW72WcJ2bo/QSYGzVrRYcw +# VAYDVR0RBE0wS6RJMEcxLTArBgNVBAsTJE1pY3Jvc29mdCBJcmVsYW5kIE9wZXJh +# dGlvbnMgTGltaXRlZDEWMBQGA1UEBRMNMjMwMDEyKzQ1NDEzNjAfBgNVHSMEGDAW +# gBRIbmTlUAXTgqoXNzcitW2oynUClTBUBgNVHR8ETTBLMEmgR6BFhkNodHRwOi8v +# d3d3Lm1pY3Jvc29mdC5jb20vcGtpb3BzL2NybC9NaWNDb2RTaWdQQ0EyMDExXzIw +# MTEtMDctMDguY3JsMGEGCCsGAQUFBwEBBFUwUzBRBggrBgEFBQcwAoZFaHR0cDov +# L3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9jZXJ0cy9NaWNDb2RTaWdQQ0EyMDEx +# XzIwMTEtMDctMDguY3J0MAwGA1UdEwEB/wQCMAAwDQYJKoZIhvcNAQELBQADggIB +# AJTwROaHvogXgixWjyjvLfiRgqI2QK8GoG23eqAgNjX7V/WdUWBbs0aIC3k49cd0 +# zdq+JJImixcX6UOTpz2LZPFSh23l0/Mo35wG7JXUxgO0U+5drbQht5xoMl1n7/TQ +# 4iKcmAYSAPxTq5lFnoV2+fAeljVA7O43szjs7LR09D0wFHwzZco/iE8Hlakl23ZT +# 7FnB5AfU2hwfv87y3q3a5qFiugSykILpK0/vqnlEVB0KAdQVzYULQ/U4eFEjnis3 +# Js9UrAvtIhIs26445Rj3UP6U4GgOjgQonlRA+mDlsh78wFSGbASIvK+fkONUhvj8 +# B8ZHNn4TFfnct+a0ZueY4f6aRPxr8beNSUKn7QW/FQmn422bE7KfnqWncsH7vbNh +# G929prVHPsaa7J22i9wyHj7m0oATXJ+YjfyoEAtd5/NyIYaE4Uu0j1EhuYUo5VaJ +# JnMaTER0qX8+/YZRWrFN/heps41XNVjiAawpbAa0fUa3R9RNBjPiBnM0gvNPorM4 +# dsV2VJ8GluIQOrJlOvuCrOYDGirGnadOmQ21wPBoGFCWpK56PxzliKsy5NNmAXcE +# x7Qb9vUjY1WlYtrdwOXTpxN4slzIht69BaZlLIjLVWwqIfuNrhHKNDM9K+v7vgrI +# bf7l5/665g0gjQCDCN6Q5sxuttTAEKtJeS/pkpI+DbZ/MIIHejCCBWKgAwIBAgIK +# YQ6Q0gAAAAAAAzANBgkqhkiG9w0BAQsFADCBiDELMAkGA1UEBhMCVVMxEzARBgNV +# BAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoTFU1pY3Jv +# c29mdCBDb3Jwb3JhdGlvbjEyMDAGA1UEAxMpTWljcm9zb2Z0IFJvb3QgQ2VydGlm +# aWNhdGUgQXV0aG9yaXR5IDIwMTEwHhcNMTEwNzA4MjA1OTA5WhcNMjYwNzA4MjEw +# OTA5WjB+MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE +# BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSgwJgYD +# VQQDEx9NaWNyb3NvZnQgQ29kZSBTaWduaW5nIFBDQSAyMDExMIICIjANBgkqhkiG +# 9w0BAQEFAAOCAg8AMIICCgKCAgEAq/D6chAcLq3YbqqCEE00uvK2WCGfQhsqa+la +# UKq4BjgaBEm6f8MMHt03a8YS2AvwOMKZBrDIOdUBFDFC04kNeWSHfpRgJGyvnkmc +# 6Whe0t+bU7IKLMOv2akrrnoJr9eWWcpgGgXpZnboMlImEi/nqwhQz7NEt13YxC4D +# dato88tt8zpcoRb0RrrgOGSsbmQ1eKagYw8t00CT+OPeBw3VXHmlSSnnDb6gE3e+ +# lD3v++MrWhAfTVYoonpy4BI6t0le2O3tQ5GD2Xuye4Yb2T6xjF3oiU+EGvKhL1nk +# kDstrjNYxbc+/jLTswM9sbKvkjh+0p2ALPVOVpEhNSXDOW5kf1O6nA+tGSOEy/S6 +# A4aN91/w0FK/jJSHvMAhdCVfGCi2zCcoOCWYOUo2z3yxkq4cI6epZuxhH2rhKEmd +# X4jiJV3TIUs+UsS1Vz8kA/DRelsv1SPjcF0PUUZ3s/gA4bysAoJf28AVs70b1FVL +# 5zmhD+kjSbwYuER8ReTBw3J64HLnJN+/RpnF78IcV9uDjexNSTCnq47f7Fufr/zd +# sGbiwZeBe+3W7UvnSSmnEyimp31ngOaKYnhfsi+E11ecXL93KCjx7W3DKI8sj0A3 +# T8HhhUSJxAlMxdSlQy90lfdu+HggWCwTXWCVmj5PM4TasIgX3p5O9JawvEagbJjS +# 4NaIjAsCAwEAAaOCAe0wggHpMBAGCSsGAQQBgjcVAQQDAgEAMB0GA1UdDgQWBBRI +# bmTlUAXTgqoXNzcitW2oynUClTAZBgkrBgEEAYI3FAIEDB4KAFMAdQBiAEMAQTAL +# BgNVHQ8EBAMCAYYwDwYDVR0TAQH/BAUwAwEB/zAfBgNVHSMEGDAWgBRyLToCMZBD +# uRQFTuHqp8cx0SOJNDBaBgNVHR8EUzBRME+gTaBLhklodHRwOi8vY3JsLm1pY3Jv +# c29mdC5jb20vcGtpL2NybC9wcm9kdWN0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFf +# MDNfMjIuY3JsMF4GCCsGAQUFBwEBBFIwUDBOBggrBgEFBQcwAoZCaHR0cDovL3d3 +# dy5taWNyb3NvZnQuY29tL3BraS9jZXJ0cy9NaWNSb29DZXJBdXQyMDExXzIwMTFf +# MDNfMjIuY3J0MIGfBgNVHSAEgZcwgZQwgZEGCSsGAQQBgjcuAzCBgzA/BggrBgEF +# BQcCARYzaHR0cDovL3d3dy5taWNyb3NvZnQuY29tL3BraW9wcy9kb2NzL3ByaW1h +# cnljcHMuaHRtMEAGCCsGAQUFBwICMDQeMiAdAEwAZQBnAGEAbABfAHAAbwBsAGkA +# YwB5AF8AcwB0AGEAdABlAG0AZQBuAHQALiAdMA0GCSqGSIb3DQEBCwUAA4ICAQBn +# 8oalmOBUeRou09h0ZyKbC5YR4WOSmUKWfdJ5DJDBZV8uLD74w3LRbYP+vj/oCso7 +# v0epo/Np22O/IjWll11lhJB9i0ZQVdgMknzSGksc8zxCi1LQsP1r4z4HLimb5j0b +# pdS1HXeUOeLpZMlEPXh6I/MTfaaQdION9MsmAkYqwooQu6SpBQyb7Wj6aC6VoCo/ +# KmtYSWMfCWluWpiW5IP0wI/zRive/DvQvTXvbiWu5a8n7dDd8w6vmSiXmE0OPQvy +# CInWH8MyGOLwxS3OW560STkKxgrCxq2u5bLZ2xWIUUVYODJxJxp/sfQn+N4sOiBp +# mLJZiWhub6e3dMNABQamASooPoI/E01mC8CzTfXhj38cbxV9Rad25UAqZaPDXVJi +# hsMdYzaXht/a8/jyFqGaJ+HNpZfQ7l1jQeNbB5yHPgZ3BtEGsXUfFL5hYbXw3MYb +# BL7fQccOKO7eZS/sl/ahXJbYANahRr1Z85elCUtIEJmAH9AAKcWxm6U/RXceNcbS +# oqKfenoi+kiVH6v7RyOA9Z74v2u3S5fi63V4GuzqN5l5GEv/1rMjaHXmr/r8i+sL +# gOppO6/8MO0ETI7f33VtY5E90Z1WTk+/gFcioXgRMiF670EKsT/7qMykXcGhiJtX +# cVZOSEXAQsmbdlsKgEhr/Xmfwb1tbWrJUnMTDXpQzTGCFVswghVXAgEBMIGVMH4x +# CzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRt +# b25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xKDAmBgNVBAMTH01p +# Y3Jvc29mdCBDb2RlIFNpZ25pbmcgUENBIDIwMTECEzMAAAFSm0CfUFaZdYgAAAAA +# AVIwDQYJYIZIAWUDBAIBBQCgga4wGQYJKoZIhvcNAQkDMQwGCisGAQQBgjcCAQQw +# HAYKKwYBBAGCNwIBCzEOMAwGCisGAQQBgjcCARUwLwYJKoZIhvcNAQkEMSIEIF81 +# uWLyDAVYFRKBYmFuri8rynQzScrsGbN5kcR5OrTuMEIGCisGAQQBgjcCAQwxNDAy +# oBSAEgBNAGkAYwByAG8AcwBvAGYAdKEagBhodHRwOi8vd3d3Lm1pY3Jvc29mdC5j +# b20wDQYJKoZIhvcNAQEBBQAEggEApRx0KHoURAwBP0m2I0PTsLsrP6SKETudSFYr +# 9e4PFpg8xqSAwXEYgi82wKazpX9L6abFec/ycHaj95SwYm21DLLfRU4VioQunWfS +# YSs6SjhfMGXj/xH0khSkHzAinC9XOr7MBdEftEBys7bXEzEhmuVve8ua1mz+7cEC +# G+4ZUwxuojoWVcYqXaTuVWtCqtRqMT/8usWI5vcIVnMOB4DjYDKnykBm4P9zp5dm +# O9Mwygww2Q1adhHw45xrwxC+tVR1pPoSjj65We6mYK3OZiLqw8PogCh0m80Uoy8G +# syIR8ZVQvr5kn8USnbJdjl9bSngAl2fD2MHRpCuq77X6cuwqT6GCEuUwghLhBgor +# BgEEAYI3AwMBMYIS0TCCEs0GCSqGSIb3DQEHAqCCEr4wghK6AgEDMQ8wDQYJYIZI +# AWUDBAIBBQAwggFRBgsqhkiG9w0BCRABBKCCAUAEggE8MIIBOAIBAQYKKwYBBAGE +# WQoDATAxMA0GCWCGSAFlAwQCAQUABCBPmVIwosrttkn8SN1lwXB51aa+NpqsEDSi +# EQJhzj6NfQIGXioI6i4+GBMyMDIwMDIxMTE5MzgwOS45NzlaMASAAgH0oIHQpIHN +# MIHKMQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMH +# UmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSUwIwYDVQQL +# ExxNaWNyb3NvZnQgQW1lcmljYSBPcGVyYXRpb25zMSYwJAYDVQQLEx1UaGFsZXMg +# VFNTIEVTTjo0OUJDLUUzN0EtMjMzQzElMCMGA1UEAxMcTWljcm9zb2Z0IFRpbWUt +# U3RhbXAgU2VydmljZaCCDjwwggTxMIID2aADAgECAhMzAAABFzFgfgd2MZ4JAAAA +# AAEXMA0GCSqGSIb3DQEBCwUAMHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNo +# aW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29y +# cG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEw +# MB4XDTE5MTExMzIxNDAzNFoXDTIxMDIxMTIxNDAzNFowgcoxCzAJBgNVBAYTAlVT +# MRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQK +# ExVNaWNyb3NvZnQgQ29ycG9yYXRpb24xJTAjBgNVBAsTHE1pY3Jvc29mdCBBbWVy +# aWNhIE9wZXJhdGlvbnMxJjAkBgNVBAsTHVRoYWxlcyBUU1MgRVNOOjQ5QkMtRTM3 +# QS0yMzNDMSUwIwYDVQQDExxNaWNyb3NvZnQgVGltZS1TdGFtcCBTZXJ2aWNlMIIB +# IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAxqFSny2tzQPKyHsRVvYTTkbi +# rctESR1afXJuUvU88FdaS7dm9CIbkt+Jv+nU92drSE8YQrg4KYuDB/2URX8qAVi3 +# 4n2AGQDp8uIHzye8WErnkmM+ZFeX9wW9IlLU4TvIimcw9Kg707OwpPwm/aa1Ojks +# m0rGF2503ded5a0Vzr/y14M5FNY/WovDlZ34vwwvGbHYQAOQlVanQbSw1EoMgoMo +# +hputRDgenr/kxnk/Pi9jxZlsoNyMjmmFOJghfyh9eympuvA6Q5kIZK1L6fHChIf +# i29hc1B94HwGMirBP1/k/Z/ayeoXYyUVhPqFYbj+gRT2Yk8oytRgUtwa/pLtKwID +# AQABo4IBGzCCARcwHQYDVR0OBBYEFK5U21bfiYsP2k605Lv4OGTorcMFMB8GA1Ud +# IwQYMBaAFNVjOlyKMZDzQ3t8RhvFM2hahW1VMFYGA1UdHwRPME0wS6BJoEeGRWh0 +# dHA6Ly9jcmwubWljcm9zb2Z0LmNvbS9wa2kvY3JsL3Byb2R1Y3RzL01pY1RpbVN0 +# YVBDQV8yMDEwLTA3LTAxLmNybDBaBggrBgEFBQcBAQROMEwwSgYIKwYBBQUHMAKG +# Pmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9wa2kvY2VydHMvTWljVGltU3RhUENB +# XzIwMTAtMDctMDEuY3J0MAwGA1UdEwEB/wQCMAAwEwYDVR0lBAwwCgYIKwYBBQUH +# AwgwDQYJKoZIhvcNAQELBQADggEBAF/XhRmleYpd+N82g9IgsF+KM0xB2wVNGZTw +# X3UIUwOE3kfoziyzQVZZjpSOZ4XRkSfF6A62ApQuGAUtlshh7weUHVNjmyqBxfSp +# u2+bhsF8ii9KcRuu1jkAgRbuZ4xrJ7+W69dDK5yJ4AdO17cBsFP6rne+KTupaEDF +# 8PBlyuF/C38mZEO6bl3hQBm4d+zMRD7Tsv6GM3uBRkzKDu6dAWTksWjT2G857TyV +# fcNsiHFP2qT3CCRIhIvV/6tZ/BFSbyAa7UscagiOzhmjyUDmQadthCgYgTH99Vzx +# Xcv1RRAo2ZbF6oMRGbIdYf6UcVi7lNN+r3Xr/hNP8H+LELNyZ4wwggZxMIIEWaAD +# AgECAgphCYEqAAAAAAACMA0GCSqGSIb3DQEBCwUAMIGIMQswCQYDVQQGEwJVUzET +# MBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UEBxMHUmVkbW9uZDEeMBwGA1UEChMV +# TWljcm9zb2Z0IENvcnBvcmF0aW9uMTIwMAYDVQQDEylNaWNyb3NvZnQgUm9vdCBD +# ZXJ0aWZpY2F0ZSBBdXRob3JpdHkgMjAxMDAeFw0xMDA3MDEyMTM2NTVaFw0yNTA3 +# MDEyMTQ2NTVaMHwxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpXYXNoaW5ndG9uMRAw +# DgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNyb3NvZnQgQ29ycG9yYXRpb24x +# JjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1wIFBDQSAyMDEwMIIBIjANBgkq +# hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqR0NvHcRijog7PwTl/X6f2mUa3RUENWl +# CgCChfvtfGhLLF/Fw+Vhwna3PmYrW/AVUycEMR9BGxqVHc4JE458YTBZsTBED/Fg +# iIRUQwzXTbg4CLNC3ZOs1nMwVyaCo0UN0Or1R4HNvyRgMlhgRvJYR4YyhB50YWeR +# X4FUsc+TTJLBxKZd0WETbijGGvmGgLvfYfxGwScdJGcSchohiq9LZIlQYrFd/Xcf +# PfBXday9ikJNQFHRD5wGPmd/9WbAA5ZEfu/QS/1u5ZrKsajyeioKMfDaTgaRtogI +# Neh4HLDpmc085y9Euqf03GS9pAHBIAmTeM38vMDJRF1eFpwBBU8iTQIDAQABo4IB +# 5jCCAeIwEAYJKwYBBAGCNxUBBAMCAQAwHQYDVR0OBBYEFNVjOlyKMZDzQ3t8RhvF +# M2hahW1VMBkGCSsGAQQBgjcUAgQMHgoAUwB1AGIAQwBBMAsGA1UdDwQEAwIBhjAP +# BgNVHRMBAf8EBTADAQH/MB8GA1UdIwQYMBaAFNX2VsuP6KJcYmjRPZSQW9fOmhjE +# MFYGA1UdHwRPME0wS6BJoEeGRWh0dHA6Ly9jcmwubWljcm9zb2Z0LmNvbS9wa2kv +# Y3JsL3Byb2R1Y3RzL01pY1Jvb0NlckF1dF8yMDEwLTA2LTIzLmNybDBaBggrBgEF +# BQcBAQROMEwwSgYIKwYBBQUHMAKGPmh0dHA6Ly93d3cubWljcm9zb2Z0LmNvbS9w +# a2kvY2VydHMvTWljUm9vQ2VyQXV0XzIwMTAtMDYtMjMuY3J0MIGgBgNVHSABAf8E +# gZUwgZIwgY8GCSsGAQQBgjcuAzCBgTA9BggrBgEFBQcCARYxaHR0cDovL3d3dy5t +# aWNyb3NvZnQuY29tL1BLSS9kb2NzL0NQUy9kZWZhdWx0Lmh0bTBABggrBgEFBQcC +# AjA0HjIgHQBMAGUAZwBhAGwAXwBQAG8AbABpAGMAeQBfAFMAdABhAHQAZQBtAGUA +# bgB0AC4gHTANBgkqhkiG9w0BAQsFAAOCAgEAB+aIUQ3ixuCYP4FxAz2do6Ehb7Pr +# psz1Mb7PBeKp/vpXbRkws8LFZslq3/Xn8Hi9x6ieJeP5vO1rVFcIK1GCRBL7uVOM +# zPRgEop2zEBAQZvcXBf/XPleFzWYJFZLdO9CEMivv3/Gf/I3fVo/HPKZeUqRUgCv +# OA8X9S95gWXZqbVr5MfO9sp6AG9LMEQkIjzP7QOllo9ZKby2/QThcJ8ySif9Va8v +# /rbljjO7Yl+a21dA6fHOmWaQjP9qYn/dxUoLkSbiOewZSnFjnXshbcOco6I8+n99 +# lmqQeKZt0uGc+R38ONiU9MalCpaGpL2eGq4EQoO4tYCbIjggtSXlZOz39L9+Y1kl +# D3ouOVd2onGqBooPiRa6YacRy5rYDkeagMXQzafQ732D8OE7cQnfXXSYIghh2rBQ +# Hm+98eEA3+cxB6STOvdlR3jo+KhIq/fecn5ha293qYHLpwmsObvsxsvYgrRyzR30 +# uIUBHoD7G4kqVDmyW9rIDVWZeodzOwjmmC3qjeAzLhIp9cAvVCch98isTtoouLGp +# 25ayp0Kiyc8ZQU3ghvkqmqMRZjDTu3QyS99je/WZii8bxyGvWbWu3EQ8l1Bx16HS +# xVXjad5XwdHeMMD9zOZN+w2/XU/pnR4ZOC+8z1gFLu8NoFA12u8JJxzVs341Hgi6 +# 2jbb01+P3nSISRKhggLOMIICNwIBATCB+KGB0KSBzTCByjELMAkGA1UEBhMCVVMx +# EzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQxHjAcBgNVBAoT +# FU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjElMCMGA1UECxMcTWljcm9zb2Z0IEFtZXJp +# Y2EgT3BlcmF0aW9uczEmMCQGA1UECxMdVGhhbGVzIFRTUyBFU046NDlCQy1FMzdB +# LTIzM0MxJTAjBgNVBAMTHE1pY3Jvc29mdCBUaW1lLVN0YW1wIFNlcnZpY2WiIwoB +# ATAHBgUrDgMCGgMVAJ30ZSoeEVHzeriZXIZc2kRjMEHSoIGDMIGApH4wfDELMAkG +# A1UEBhMCVVMxEzARBgNVBAgTCldhc2hpbmd0b24xEDAOBgNVBAcTB1JlZG1vbmQx +# HjAcBgNVBAoTFU1pY3Jvc29mdCBDb3Jwb3JhdGlvbjEmMCQGA1UEAxMdTWljcm9z +# b2Z0IFRpbWUtU3RhbXAgUENBIDIwMTAwDQYJKoZIhvcNAQEFBQACBQDh7OraMCIY +# DzIwMjAwMjExMTY1NjU4WhgPMjAyMDAyMTIxNjU2NThaMHcwPQYKKwYBBAGEWQoE +# ATEvMC0wCgIFAOHs6toCAQAwCgIBAAICDHcCAf8wBwIBAAICEeowCgIFAOHuPFoC +# AQAwNgYKKwYBBAGEWQoEAjEoMCYwDAYKKwYBBAGEWQoDAqAKMAgCAQACAwehIKEK +# MAgCAQACAwGGoDANBgkqhkiG9w0BAQUFAAOBgQAIQoy3ZMxHIKNANY3lfF1VdNDi +# NSdLBuAxaZT/SELEAcf7jx4EqhudyZJzBtogWVMBfdRpVKY2NGWbQIOVWGp/soCQ +# kkmTtZ445RDAzg/pyIYgH8eOKWYLSC9Q3CkX+McO4Abfw/Te2MkDRwEg3IrmjI+a +# FqaKJXgEyPLTqjI1HzGCAw0wggMJAgEBMIGTMHwxCzAJBgNVBAYTAlVTMRMwEQYD +# VQQIEwpXYXNoaW5ndG9uMRAwDgYDVQQHEwdSZWRtb25kMR4wHAYDVQQKExVNaWNy +# b3NvZnQgQ29ycG9yYXRpb24xJjAkBgNVBAMTHU1pY3Jvc29mdCBUaW1lLVN0YW1w +# IFBDQSAyMDEwAhMzAAABFzFgfgd2MZ4JAAAAAAEXMA0GCWCGSAFlAwQCAQUAoIIB +# SjAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwLwYJKoZIhvcNAQkEMSIEIJZr +# OfhAmqiwRKN5hrxHKSpPRP6bPp1SHQSjhiYULhQeMIH6BgsqhkiG9w0BCRACLzGB +# 6jCB5zCB5DCBvQQgbGlmyOKyWcxfupgfj2plOdLNhEQJid/q7jgfyNrYXyQwgZgw +# gYCkfjB8MQswCQYDVQQGEwJVUzETMBEGA1UECBMKV2FzaGluZ3RvbjEQMA4GA1UE +# BxMHUmVkbW9uZDEeMBwGA1UEChMVTWljcm9zb2Z0IENvcnBvcmF0aW9uMSYwJAYD +# VQQDEx1NaWNyb3NvZnQgVGltZS1TdGFtcCBQQ0EgMjAxMAITMwAAARcxYH4HdjGe +# CQAAAAABFzAiBCAZUVOBfoTjHPuLd1qM8lILXfAlbas7KbCjhuSttQViZzANBgkq +# hkiG9w0BAQsFAASCAQA2bpUBF7NR1HNlKA8XsMagSeu80n+Duep1YLCdJvswtda7 +# 1pvLif8JCQnSogM1reIuDA4jm/Sgcqskg1rTfTPpBh7DdVdoxtFcJaJerrNA7XnM +# RAtGrv4sUopRRX6hzHhpRKXPIOKZGYZIm1jrMHKWpjFviGp5S3QIKlEJMPL0eL+X +# qnJpV2A2jmVLDgeGpBLy1bSJbpjaA8ihgmwZuXzsclHZ4mZVbJQxbgdEsJXqi01h +# C/4vUkSd34s87E+eUoQJ+XDXPIeJM6YfTY0rAB3bdz0XY0u8wFR+QmyygivCjZHm +# yR3lDDWaTXPr6TukuYObj48WUfpS1TssVoDTVnfn +# SIG # End signature block diff --git a/src/NodePilot.Engine/PowerShell/Modules/NOTICE.md b/src/NodePilot.Engine/PowerShell/Modules/NOTICE.md new file mode 100644 index 00000000..f0dc3e43 --- /dev/null +++ b/src/NodePilot.Engine/PowerShell/Modules/NOTICE.md @@ -0,0 +1,20 @@ +# Bundled PowerShell modules + +## Microsoft.PowerShell.Archive 1.2.5 + +- Source: PowerShell Gallery, `Microsoft.PowerShell.Archive` 1.2.5 (unmodified; the same version + that full pwsh 7.x installations bundle). Files: module manifest, script module, en-US resources. +- License: MIT — https://github.com/PowerShell/Microsoft.PowerShell.Archive +- Copyright © Microsoft Corporation. All rights reserved. + +Why it is bundled: the in-process runspace pool runs on the *PowerShell SDK* NuGet package, which +ships only the eight core modules — `Microsoft.PowerShell.Archive` is not among them. Without it, +a local `Compress-Archive`/`Expand-Archive` call (the `zipOperation` activity on the localhost +bypass path) triggered PowerShell's implicit Windows-PowerShell compatibility: a +`powershell.exe -Version 5.1 -s` child process per pool runspace whose `WinPSCompatSession` was +never closed — the memory/thread leak diagnosed on 2026-07-30. The module is imported eagerly into +every pool runspace via the InitialSessionState (see `RunspaceExecutionEngine`), so the module +auto-loader never goes looking for a Windows-PowerShell copy in the first place. Implicit WinCompat +is additionally disabled process-wide for the SDK via `powershell.config.json` +(`DisableImplicitWinCompat`) so future desktop-only cmdlets fail loudly instead of silently +spawning compatibility sessions. diff --git a/src/NodePilot.Engine/PowerShell/RunspaceExecutionEngine.cs b/src/NodePilot.Engine/PowerShell/RunspaceExecutionEngine.cs index e645e88d..cd0c52f1 100644 --- a/src/NodePilot.Engine/PowerShell/RunspaceExecutionEngine.cs +++ b/src/NodePilot.Engine/PowerShell/RunspaceExecutionEngine.cs @@ -38,6 +38,30 @@ public RunspaceExecutionEngine(ILogger logger, int minRunspaces = 1, int maxRuns // to dot-source the .psm1 files those modules ship as. var iss = InitialSessionState.CreateDefault2(); iss.ExecutionPolicy = Microsoft.PowerShell.ExecutionPolicy.Bypass; + + // The PowerShell SDK ships only the eight core modules — Microsoft.PowerShell.Archive + // (Compress-Archive / Expand-Archive, used by the zipOperation activity on the + // localhost-bypass path) is not among them. Left to itself, the module auto-loader + // found the Windows PowerShell 5.1 copy under System32 and loaded it through the + // implicit WinCompat feature: one `powershell.exe -Version 5.1 -s` child process per + // pool runspace, registered as "WinPSCompatSession" in that runspace's session + // repository and never closed. Pool runspaces live forever, so sessions, their + // transport threads and ~148 MB child processes accumulated without bound (the + // 2026-07-30 9-GB leak). Importing the bundled copy eagerly means the auto-loader + // never goes looking; implicit WinCompat itself is additionally disabled via + // powershell.config.json next to the SDK assembly (see Directory.Build.targets), + // so any future desktop-only cmdlet fails loudly instead of silently leaking. + var archiveManifest = Path.Combine(AppContext.BaseDirectory, + "PSModules", "Microsoft.PowerShell.Archive", "Microsoft.PowerShell.Archive.psd1"); + if (File.Exists(archiveManifest)) + iss.ImportPSModule(archiveManifest); + else + logger.LogWarning( + "Bundled Microsoft.PowerShell.Archive module not found at {Path} — local " + + "Compress-Archive/Expand-Archive (zipOperation) will fail in the runspace pool. " + + "The output layout is missing the PSModules folder from NodePilot.Engine.", + archiveManifest); + _pool = RunspaceFactory.CreateRunspacePool(iss); _pool.SetMinRunspaces(_minRunspaces); _pool.SetMaxRunspaces(_maxRunspaces); diff --git a/src/NodePilot.Engine/PowerShell/powershell.config.json b/src/NodePilot.Engine/PowerShell/powershell.config.json new file mode 100644 index 00000000..2c231da3 --- /dev/null +++ b/src/NodePilot.Engine/PowerShell/powershell.config.json @@ -0,0 +1,3 @@ +{ + "DisableImplicitWinCompat": true +} diff --git a/src/NodePilot.Mcp/Tools/TelemetryTools.cs b/src/NodePilot.Mcp/Tools/TelemetryTools.cs index 91e87e5d..ce8ad474 100644 --- a/src/NodePilot.Mcp/Tools/TelemetryTools.cs +++ b/src/NodePilot.Mcp/Tools/TelemetryTools.cs @@ -42,7 +42,7 @@ public async Task GetDashboardStats(CancellationToken cancellationToken } [McpServerTool(Name = "get_operations_graph", ReadOnly = true)] - [Description("Live-ops Mission-Control snapshot (RBAC-scoped): workflow nodes (name, folder, enabled, live runningCount, lastStatus, per-node canRun/canEdit), call edges between workflows (startWorkflow/forEach, with refStatus Resolved|Dynamic|Unresolved|Ambiguous), currently-running executions, and recently finished executions within the selected window. 'meta' carries the overdue threshold plus recentTruncated/oldestReturnedCompletedAt so an empty stretch is never mistaken for 'nothing ran'. Answers 'what is running right now, what just finished, and how do workflows call each other?'.")] + [Description("Live-ops Mission-Control snapshot (RBAC-scoped): workflow nodes (name, folder, enabled, live runningCount, lastStatus, per-node canRun/canEdit), call edges between workflows (startWorkflow/forEach, with refStatus Resolved|Dynamic|Unresolved|Ambiguous), currently-running executions, and recently finished executions within the selected window. 'recent' is capped at the newest 1000; when it is, 'meta.recentTruncated' is true and 'density' carries the per-workflow run counts for the whole window in 'meta.densityBucketSeconds' buckets (bucketIndex 0 starts at meta.recentSinceUtc), each with total/failed/cancelled — so 'how much ran in the last four hours' is answerable even though not every run is listed individually. 'meta.oldestReturnedCompletedAt' marks where the individual list stops and 'meta.densityCapped' says the counts are a floor. Answers 'what is running right now, what just finished, and how do workflows call each other?'.")] public async Task GetOperationsGraph( [Description("Look-back window for finished runs, in minutes: 20 (default), 60 or 240. Other values clamp to 20. Running executions are always returned in full.")] int windowMinutes = 20, CancellationToken cancellationToken = default) diff --git a/src/nodepilot-desktop/assets/.gitignore b/src/nodepilot-desktop/assets/.gitignore index ba7449d6..3188c2c2 100644 --- a/src/nodepilot-desktop/assets/.gitignore +++ b/src/nodepilot-desktop/assets/.gitignore @@ -1,5 +1,5 @@ -# Everything in this folder is GENERATED by deploy/desktop/Build-DesktopInstaller.ps1 from the -# tracked brand asset src/nodepilot-ui/public/appicon.png — never commit the results. +# Everything in this folder is GENERATED by scripts/generate-desktop-icons.ps1 from the tracked +# brand assets src/nodepilot-ui/public/appicon-.png — never commit the results. # Explicit, because the root .gitignore ignores *.png but not *.ico, which left this folder in a # half-ignored state. * diff --git a/src/nodepilot-desktop/package.json b/src/nodepilot-desktop/package.json index 0346556a..9bd7a0a3 100644 --- a/src/nodepilot-desktop/package.json +++ b/src/nodepilot-desktop/package.json @@ -8,6 +8,7 @@ "main": "dist/main.js", "scripts": { "build": "tsc && node scripts/copy-static.mjs", + "icons": "powershell -NoProfile -ExecutionPolicy Bypass -File ../../scripts/generate-desktop-icons.ps1", "typecheck": "tsc --noEmit", "test": "vitest", "test:run": "vitest run", diff --git a/src/nodepilot-desktop/src/main.ts b/src/nodepilot-desktop/src/main.ts index 6e12906d..b57df9fa 100644 --- a/src/nodepilot-desktop/src/main.ts +++ b/src/nodepilot-desktop/src/main.ts @@ -4,11 +4,15 @@ import { join } from 'node:path'; import { spawn } from 'node:child_process'; import { loadDesktopConfig, type DesktopConfig } from './config'; import { hardenSession, hardenWindow } from './security'; +import { defaultIcons, skinIconsForFavicons, type SkinIcons } from './skins'; /** Persistent session partition shared by the login request, the setup window, and the SPA window * so the auth cookies set during first-run setup are available to the app. */ const PARTITION = 'persist:nodepilot'; +/** Generated icon set — sits next to dist/ inside the asar (see scripts/generate-desktop-icons.ps1). */ +const ASSETS_DIR = join(__dirname, '..', 'assets'); + /** Per-user handoff copy of the admin bootstrap token, written by the elevated installer into the * installing user's profile (the real token under ProgramData is SYSTEM-owned and unreadable here). */ const HANDOFF_PATH = join(process.env.LOCALAPPDATA ?? '', 'NodePilot', 'admin-setup.handoff'); @@ -19,6 +23,9 @@ let setupWindow: BrowserWindow | null = null; let splashWindow: BrowserWindow | null = null; let tray: Tray | null = null; let quitting = false; +/** Icon pair currently in use. Starts on the build-time default (blue) and follows the SPA's + * color skin from its first favicon report onwards. */ +let icons: SkinIcons = defaultIcons(ASSETS_DIR); const delay = (ms: number): Promise => new Promise((resolve) => setTimeout(resolve, ms)); @@ -161,6 +168,44 @@ async function handleSetupComplete( return { ok: true }; } +// ------------------------------------------------------------------------------------------- +// Icons — the shell's window + tray icon track the color skin selected in the SPA. +// ------------------------------------------------------------------------------------------- +/** `undefined` (not an empty image) when the asset is missing, so Electron keeps its default + * rather than being handed a blank icon. */ +function windowIcon(): Electron.NativeImage | undefined { + const image = nativeImage.createFromPath(icons.window); + return image.isEmpty() ? undefined : image; +} + +function trayIcon(): Electron.NativeImage { + const candidate = nativeImage.createFromPath(icons.tray); + if (!candidate.isEmpty()) return candidate; + // 1x1 fallback so Tray construction never fails on a build missing the asset. + return nativeImage.createFromDataURL( + 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='); +} + +/** + * Repaints every icon after the SPA reported a new favicon. The SPA rewrites `` + * to `/appicon-.png` on every skin switch, which Chromium surfaces here — no preload and no + * IPC channel on the production window. An unknown skin (or a build without the generated set) + * simply leaves the current icon in place. + */ +function applySkinIcons(favicons: readonly string[]): void { + const next = skinIconsForFavicons(ASSETS_DIR, favicons); + if (!next || next.window === icons.window) return; + icons = next; + + const image = windowIcon(); + if (image) { + for (const win of [mainWindow, setupWindow]) { + if (win && !win.isDestroyed()) win.setIcon(image); + } + } + try { tray?.setImage(trayIcon()); } catch { /* tray already destroyed */ } +} + // ------------------------------------------------------------------------------------------- // Windows // ------------------------------------------------------------------------------------------- @@ -186,12 +231,14 @@ function openMainWindow(): void { minHeight: 640, show: false, title: 'NodePilot', + icon: windowIcon(), backgroundColor: '#0b1020', // NO preload and NO IPC for the production SPA window. webPreferences: baseWebPreferences(), }); mainWindow.removeMenu(); hardenWindow(mainWindow, config.origin); + mainWindow.webContents.on('page-favicon-updated', (_event, favicons) => applySkinIcons(favicons)); mainWindow.once('ready-to-show', () => mainWindow?.show()); mainWindow.on('close', (event) => { if (!quitting) { event.preventDefault(); mainWindow?.hide(); } @@ -207,6 +254,7 @@ function openSetupWindow(): void { height: 620, resizable: false, title: 'NodePilot — First-time setup', + icon: windowIcon(), backgroundColor: '#0b1020', webPreferences: { ...baseWebPreferences(), @@ -222,6 +270,7 @@ function openSetupWindow(): void { function showSplash(): void { splashWindow = new BrowserWindow({ width: 420, height: 240, frame: false, resizable: false, show: true, + icon: windowIcon(), backgroundColor: '#0b1020', webPreferences: { contextIsolation: true, sandbox: true }, }); @@ -264,14 +313,6 @@ function createTray(): void { } } -function trayIcon(): Electron.NativeImage { - const candidate = nativeImage.createFromPath(join(__dirname, '..', 'assets', 'tray.png')); - if (!candidate.isEmpty()) return candidate; - // 1x1 fallback so Tray construction never fails on a build missing the asset. - return nativeImage.createFromDataURL( - 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg=='); -} - /** * Restarts ONLY the API service, using the validated service name from desktop.json, via an * elevated (UAC) PowerShell. The background Postgres service is untouched. diff --git a/src/nodepilot-desktop/src/skins.test.ts b/src/nodepilot-desktop/src/skins.test.ts new file mode 100644 index 00000000..0befe5c2 --- /dev/null +++ b/src/nodepilot-desktop/src/skins.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect } from 'vitest'; +import { join } from 'node:path'; +import { defaultIcons, skinFromFaviconUrl, skinIcons, skinIconsForFavicons } from './skins'; + +const ASSETS = join('C:', 'app', 'assets'); +const skinPath = (name: string) => join(ASSETS, 'skins', name); + +/** Stand-in for a generated icon set covering exactly these skins. */ +const generatedFor = (...skins: string[]) => { + const files = new Set(skins.flatMap((s) => [skinPath(`${s}.png`), skinPath(`${s}-tray.png`)])); + return (path: string) => files.has(path); +}; + +describe('defaultIcons', () => { + it('points at the build-time default pair next to the app', () => { + expect(defaultIcons(ASSETS)).toEqual({ + window: join(ASSETS, 'icon.png'), + tray: join(ASSETS, 'tray.png'), + }); + }); +}); + +describe('skinFromFaviconUrl', () => { + it.each([ + ['https://localhost:5001/appicon-dark.png', 'dark'], + ['https://localhost:5001/appicon-light.png', 'light'], + ['https://localhost:5001/appicon-dark-sparkasse.png', 'dark-sparkasse'], + ['https://127.0.0.1:5001/sub/path/appicon-dark-nebula.png?v=2', 'dark-nebula'], + ])('extracts the skin id from %s', (url, expected) => { + expect(skinFromFaviconUrl(url)).toBe(expected); + }); + + it.each([ + ['https://localhost:5001/appicon.png'], // untinted source art, not a skin + ['https://localhost:5001/favicon.svg'], + ['https://localhost:5001/appicon-.png'], + ['https://localhost:5001/appicon-Dark.png'], // ids are lowercase + ['https://localhost:5001/appicon-dark.png.exe'], + ['not a url'], + [''], + ])('rejects %s', (url) => { + expect(skinFromFaviconUrl(url)).toBeNull(); + }); + + it('rejects a traversal attempt smuggled into the favicon path', () => { + expect(skinFromFaviconUrl('https://localhost:5001/appicon-..%2F..%2Fevil.png')).toBeNull(); + }); +}); + +describe('skinIcons', () => { + it('resolves both sizes of a generated skin', () => { + expect(skinIcons(ASSETS, 'dark-lila', generatedFor('dark-lila'))).toEqual({ + window: skinPath('dark-lila.png'), + tray: skinPath('dark-lila-tray.png'), + }); + }); + + it('returns null for a skin this build has no icons for', () => { + expect(skinIcons(ASSETS, 'dark-nebula', generatedFor('dark'))).toBeNull(); + }); + + it('returns null when only one of the two sizes exists', () => { + expect(skinIcons(ASSETS, 'dark', (p) => p === skinPath('dark.png'))).toBeNull(); + }); + + it('rejects an id outside the allowed charset before touching the filesystem', () => { + expect(skinIcons(ASSETS, '../../../windows/system32/evil', () => true)).toBeNull(); + expect(skinIcons(ASSETS, 'DARK', () => true)).toBeNull(); + expect(skinIcons(ASSETS, '', () => true)).toBeNull(); + }); +}); + +describe('skinIconsForFavicons', () => { + it('takes the first favicon that resolves to a generated skin', () => { + const icons = skinIconsForFavicons( + ASSETS, + [ + 'https://localhost:5001/favicon.svg', + 'https://localhost:5001/appicon-dark-nebula.png', + 'https://localhost:5001/appicon-dark.png', + ], + generatedFor('dark', 'dark-nebula'), + ); + expect(icons).toEqual({ window: skinPath('dark-nebula.png'), tray: skinPath('dark-nebula-tray.png') }); + }); + + it('skips a known-but-ungenerated skin and keeps looking', () => { + const icons = skinIconsForFavicons( + ASSETS, + ['https://localhost:5001/appicon-dark-nebula.png', 'https://localhost:5001/appicon-light.png'], + generatedFor('light'), + ); + expect(icons).toEqual({ window: skinPath('light.png'), tray: skinPath('light-tray.png') }); + }); + + it('returns null for an empty report, so the current icon stays', () => { + expect(skinIconsForFavicons(ASSETS, [], () => true)).toBeNull(); + }); + + it('returns null when nothing in the report is a skin icon', () => { + expect(skinIconsForFavicons(ASSETS, ['https://localhost:5001/favicon.ico'], () => true)).toBeNull(); + }); +}); diff --git a/src/nodepilot-desktop/src/skins.ts b/src/nodepilot-desktop/src/skins.ts new file mode 100644 index 00000000..ec1324e6 --- /dev/null +++ b/src/nodepilot-desktop/src/skins.ts @@ -0,0 +1,88 @@ +import { existsSync } from 'node:fs'; +import { join } from 'node:path'; + +/** + * Window- and tray-icon selection for the shell, following the color skin the user picked in the + * SPA. + * + * The signal is the SPA's own favicon: `applyFavicon` (nodepilot-ui/src/lib/appIcon.ts) writes + * `/appicon-.png` into `` on every skin change, and Chromium reports that + * to the main process as `page-favicon-updated`. Reusing it keeps the production SPA window + * preload-less and IPC-free — the shell reads a one-way signal the renderer already broadcasts + * instead of being handed a channel that could be talked into doing more. + * + * The skin ids are deliberately NOT mirrored here. `scripts/generate-desktop-icons.ps1` emits one + * `assets/skins/.png` per `appicon-.png` the SPA ships, so "which skins exist" is + * answered by the files on disk. A skin added to the UI without a regenerated icon set degrades + * to keeping the current icon — never to a wrong one. + */ + +/** Skin ids arrive from the renderer and end up in a path join, so they are held to the same + * strict charset the generator enforces (see $SKIN_ID there). */ +const SKIN_ID = /^[a-z][a-z0-9-]{0,31}$/; + +/** `appicon-.png` — the file-name shape `APP_ICON_BY_SKIN` produces. */ +const FAVICON_NAME = /^appicon-([a-z][a-z0-9-]{0,31})\.png$/; + +export interface SkinIcons { + /** 256px window icon — title bar, Alt+Tab, taskbar. */ + window: string; + /** 32px notification-area icon. */ + tray: string; +} + +/** + * The build-time default pair. Blue by design: the generator renders it from the default skin's + * brand asset, not from the untinted orange source art. Applied to every window until the SPA + * reports its skin, and to the exe/installer/Start-Menu entry, which cannot follow a skin at all. + */ +export function defaultIcons(assetsDir: string): SkinIcons { + return { window: join(assetsDir, 'icon.png'), tray: join(assetsDir, 'tray.png') }; +} + +/** The skin id encoded in a SPA favicon URL, or null if the URL is not one of ours. */ +export function skinFromFaviconUrl(url: string): string | null { + let name: string; + try { + name = new URL(url).pathname.split('/').pop() ?? ''; + } catch { + return null; + } + return FAVICON_NAME.exec(name)?.[1] ?? null; +} + +/** + * The generated icon pair for a skin, or null when this build has none — a dev run straight from + * source (assets/ is generated, not committed) or a skin newer than the icon set. Callers keep + * whatever icon they already show in that case. + */ +export function skinIcons( + assetsDir: string, + skin: string, + exists: (path: string) => boolean = existsSync, +): SkinIcons | null { + if (!SKIN_ID.test(skin)) return null; + const icons: SkinIcons = { + window: join(assetsDir, 'skins', `${skin}.png`), + tray: join(assetsDir, 'skins', `${skin}-tray.png`), + }; + return exists(icons.window) && exists(icons.tray) ? icons : null; +} + +/** + * Resolves the icon pair for a `page-favicon-updated` payload. Chromium may report several + * favicons; the first that resolves to a generated skin set wins. + */ +export function skinIconsForFavicons( + assetsDir: string, + favicons: readonly string[], + exists: (path: string) => boolean = existsSync, +): SkinIcons | null { + for (const url of favicons) { + const skin = skinFromFaviconUrl(url); + if (!skin) continue; + const icons = skinIcons(assetsDir, skin, exists); + if (icons) return icons; + } + return null; +} diff --git a/src/nodepilot-docs-ui/content/activities-reference.md b/src/nodepilot-docs-ui/content/activities-reference.md index 411fdbca..5ecc549e 100644 --- a/src/nodepilot-docs-ui/content/activities-reference.md +++ b/src/nodepilot-docs-ui/content/activities-reference.md @@ -17,6 +17,7 @@ Jeder Step unterstützt `config.retry` mit `maxAttempts`, `backoff`, `initialDel **Hybrid.** Führt PowerShell aus — mit gesetzter Maschine über NodePilots managed WinRM-Session; **ohne** Maschine engine-local im API-Host, wo das Script die Remote-Verbindung via `Invoke-Command`/`New-PSSession` auch selbst managen kann (SCOrch-Stil, Trade-offs siehe [Remote-Execution](configuration/remote-execution)). - **Config:** `script`, `engine` (`auto`/`pwsh`/`powershell`), `timeoutSeconds`, `isolated` (bool, nur lokal — eigener Prozess im Windows Job Object: Crash-/Leak-Containment, keine verwaisten Prozesse) + optionale Caps `memoryLimitMb` (lässt Allokationen fehlschlagen, terminiert nicht) / `maxProcesses`, `successExitCodes` (komma-separiert, opt-in Exit-Code-Gate) +- **Engine-local = PowerShell-Core-SDK:** Verfügbar sind die Core-Module, CDXML-Module des Systems (ScheduledTasks, NetTCPIP, …) und das mitgelieferte `Microsoft.PowerShell.Archive` (`Compress-`/`Expand-Archive`). Reine Windows-PowerShell-Module (Edition `Desktop` ohne Core-Flag) werden **nicht** stillschweigend über eine Kompatibilitäts-Session geladen — der Aufruf schlägt mit einer klaren Meldung fehl. Wer ein solches Modul bewusst braucht: `Import-Module -UseWindowsPowerShell` im Script (Session gehört dann dem Script), `engine: "powershell"` wählen oder den Step mit Zielmaschine über WinRM laufen lassen. - **Isolation-Robustheit:** Isolierte Läufe können nicht mehr in `Running` hängen bleiben. NodePilot serialisiert inheritable-handle-Spawns und begrenzt den stdout/stderr-Drain nach Prozess-Exit auf `Engine:IsolatedDrainGraceSeconds` (default 5 s), damit ein in einen Fremdprozess geleaktes Pipe-Handle den Step nicht dauerhaft blockiert. - **Outputs:** `output`, `error`, `param.exitCode` (immer), `param.*` (auto-captured deklarierte Variablen) - **Erfolg (fehler-basiert):** Ein Step scheitert **nur** bei einem terminierenden PowerShell-Fehler (`throw` / `Write-Error` unter `Stop`). Ein `exit N` markiert den Step nicht automatisch als fehlgeschlagen. Für exit-code-basierte Bewertung ist `successExitCodes` zu setzen, zum Beispiel `"0"`. `successExitCodes`/`param.exitCode` greifen für Native-Command-Codes (`$LASTEXITCODE`) in allen Engines; ein script-eigenes `exit N` ist nur im Prozess/isoliert-Modus als Wert sichtbar (Runspace sieht `exit` nicht). diff --git a/src/nodepilot-docs-ui/content/ai-features.md b/src/nodepilot-docs-ui/content/ai-features.md index d1d7aa60..6c9cf49f 100644 --- a/src/nodepilot-docs-ui/content/ai-features.md +++ b/src/nodepilot-docs-ui/content/ai-features.md @@ -89,6 +89,8 @@ Der Chat kann keine Workflow-Änderungen vorschlagen oder übernehmen. Antworten Die Datenbankquelle führt ausschließlich lesende Abfragen aus. Schreiboperationen werden blockiert. Geschützte Spalten und erkannte Secrets werden nicht an das Modell ausgegeben. +Die Einstiegsvorschläge auf der leeren Chat-Seite richten sich nach den verfügbaren Wissensquellen. Steht die Datenbankquelle zur Verfügung, werden Betriebsauswertungen vorgeschlagen — etwa die letzten fehlgeschlagenen Läufe, hängende Ausführungen oder nicht erreichbare Maschinen. Andernfalls erscheinen Fragen zur Dokumentation und zum Zeitplan, die auch ohne diese Quelle beantwortet werden können. + ## Abgrenzung | Aufgabe | Passende Funktion | diff --git a/src/nodepilot-docs-ui/content/cli.md b/src/nodepilot-docs-ui/content/cli.md index f2bab333..0197801a 100644 --- a/src/nodepilot-docs-ui/content/cli.md +++ b/src/nodepilot-docs-ui/content/cli.md @@ -337,6 +337,13 @@ np operations graph -o json np operations graph --window 240 # Rückblick für beendete Läufe: 20 | 60 | 240 Min ``` +Einzeln aufgelistet werden die neuesten 1000 beendeten Läufe des Fensters. Gibt es mehr, +liefert `density[]` zusätzlich gebucketete Zähler (`total`/`failed`/`cancelled` je Workflow und +Zeitscheibe) über das **ganze** Fenster — „wie viel lief in den letzten vier Stunden?" bleibt +also beantwortbar, auch wenn nicht jeder Lauf einzeln aufgeführt ist. Die Tabellenausgabe nennt +in dem Fall die Gesamtzahl; `meta.densityBucketSeconds` gibt die Scheibenbreite, +`meta.densityCapped` sagt, dass selbst die Zähler nur eine Untergrenze sind. + ## system-alert · policies (ADR 0008) ```bash diff --git a/src/nodepilot-docs-ui/content/deployment/desktop.md b/src/nodepilot-docs-ui/content/deployment/desktop.md index 1e9202f3..79d3e623 100644 --- a/src/nodepilot-docs-ui/content/deployment/desktop.md +++ b/src/nodepilot-docs-ui/content/deployment/desktop.md @@ -37,6 +37,8 @@ PostgreSQL 16 auf 127.0.0.1 Die Dienste starten beim Systemstart. Das Schließen des Electron-Fensters beendet keine Workflows oder Trigger. +Fenster- und Infobereichssymbol übernehmen die Farbe des in der Oberfläche gewählten Skins. Das Symbol der Programmdatei, des Installers und des Startmenüeintrags bleibt beim blauen Standard, da Windows diese Symbole aus der Datei selbst auflöst. + ## Installierte Pfade | Pfad | Inhalt | diff --git a/src/nodepilot-ui/e2e/README.md b/src/nodepilot-ui/e2e/README.md index d298fd20..53ef99e5 100644 --- a/src/nodepilot-ui/e2e/README.md +++ b/src/nodepilot-ui/e2e/README.md @@ -111,7 +111,7 @@ alongside `dotnet test` and `npm run test:run`. | 75 | Quick-interactions | `quick-interactions.spec.ts` | | 77 | AI workflow assistant + SSE streaming (explain + propose, role-gated apply, stale-guard; chat/script SSE mocked via `text/event-stream` body) | `ai-assistant.spec.ts` | | 9 (DR) | System-config Backup & Restore (export validation + restore preview/run) | `backup.spec.ts` | -| — (new) | Live-Ops Mission Control: real-time execution timeline (running + recently-finished bars), bar drill-down with failed-step names and the incident actions (cancel / retry / cancel-all / quarantine incl. disable-before-cancel ordering and per-node RBAC gating), stuck strip + overdue bar treatment for long-running runs, step-activity (finished count / last progress, no percentage), window selector 20 m–4 h with display freeze and truncation honesty, next-fires departure board incl. maintenance-window blackout marking, folder scoping | `operations.spec.ts` | +| — (new) | Live-Ops Mission Control: real-time execution timeline (running + recently-finished bars), bar drill-down with failed-step names and the incident actions (cancel / retry / cancel-all / quarantine incl. disable-before-cancel ordering and per-node RBAC gating), stuck strip + overdue bar treatment for long-running runs, step-activity (finished count / last progress, no percentage), window selector 20 m–4 h with display freeze and the density track that fills a window the individual bars cannot cover, next-fires departure board incl. maintenance-window blackout marking, folder scoping | `operations.spec.ts` | | 78 | Alerting rules (list/create/test-fire/secret-redaction/role-gating/gauge-scope-gate/deliveries-modal/cancelledBy-filter) | `alerting.spec.ts` | | 79 | Toolbar-layout toggle: compact ⇄ classic toolbar, persisted in designStore, toggle reachable from both layouts | `toolbar-layout.spec.ts` | | — (new) | Atelier-Designsprache: Scope-Klassen + Token-Adaption, Skin-Adaption, Header-Umschalter (role=switch), Persistenz über Reload, Classic-Suite-Pin | `designer-atelier.spec.ts` | diff --git a/src/nodepilot-ui/e2e/ai-chat.spec.ts b/src/nodepilot-ui/e2e/ai-chat.spec.ts index 5bafaf6b..6b3b278b 100644 --- a/src/nodepilot-ui/e2e/ai-chat.spec.ts +++ b/src/nodepilot-ui/e2e/ai-chat.spec.ts @@ -106,6 +106,10 @@ test.describe('AI Knowledge Chat (/ai-chat)', () => { // Empty-state title (no messages yet). await expect(page.getByRole('heading', { name: /Ask NodePilot anything/i })).toBeVisible(); + + // db:true → the operational starter prompts, not the docs-only lite set. + await expect(main.getByRole('button', { name: /last 10 failed runs/i })).toBeVisible(); + await expect(main.getByRole('button', { name: /webhook trigger/i })).toHaveCount(0); }); // 2. composer send → SSE stream (two delta frames) → token append + final answer + usage footer + send re-enabled @@ -302,6 +306,12 @@ test.describe('AI Knowledge Chat (/ai-chat)', () => { await expect(page.getByText(/^Source code$/i)).toHaveCount(0); await expect(page.getByText(/^Database$/i)).toHaveCount(0); + // Without the DB source the starter prompts fall back to the lite set — a Viewer never sees + // ops questions that would only answer "database source is not available". + const main = page.locator('#np-main-scroll'); + await expect(main.getByRole('button', { name: /webhook trigger/i })).toBeVisible(); + await expect(main.getByRole('button', { name: /last 10 failed runs/i })).toHaveCount(0); + // Composer is usable — Viewer can still ask questions. await page.getByRole('textbox', { name: /Ask about NodePilot/i }).fill('What can I see?'); await page.getByTitle(/^Send$/i).click(); diff --git a/src/nodepilot-ui/e2e/operations.spec.ts b/src/nodepilot-ui/e2e/operations.spec.ts index 32fce1ec..bda0270e 100644 --- a/src/nodepilot-ui/e2e/operations.spec.ts +++ b/src/nodepilot-ui/e2e/operations.spec.ts @@ -25,7 +25,8 @@ const GRAPH = () => ({ recent: [ { executionId: 'ex-2', workflowId: 'wf-2', status: 'Failed', startedAt: new Date(now() - 10 * MIN).toISOString(), completedAt: new Date(now() - 8 * MIN).toISOString() }, ], - meta: { overdueSeconds: 600, windowMinutes: 20, recentSinceUtc: new Date(0).toISOString(), oldestReturnedCompletedAt: null, recentTruncated: false }, + density: [], + meta: { overdueSeconds: 600, windowMinutes: 20, recentSinceUtc: new Date(0).toISOString(), oldestReturnedCompletedAt: null, recentTruncated: false, densityBucketSeconds: 0, densityCapped: false }, }); const STATS = () => ({ @@ -241,25 +242,36 @@ test('window selector re-requests the snapshot and freeze pins the view', async await expect(page.getByTestId('ops-frozen-badge')).toHaveCount(0); }); -test('a truncated window says so instead of showing empty track', async ({ page }) => { +test('a window the bars cannot cover is filled with density, not left empty', async ({ page }) => { await installDefaultMocks(page); await page.route('**/api/operations/graph*', (r) => json(r, { ...GRAPH(), + density: [ + { workflowId: 'wf-1', buckets: [{ bucketIndex: 4, total: 12, failed: 0, cancelled: 0 }] }, + { workflowId: 'wf-2', buckets: [{ bucketIndex: 6, total: 20, failed: 3, cancelled: 0 }] }, + ], meta: { overdueSeconds: 600, windowMinutes: 240, recentSinceUtc: new Date(now() - 240 * MIN).toISOString(), oldestReturnedCompletedAt: new Date(now() - 8 * MIN).toISOString(), - recentTruncated: true, + recentTruncated: true, densityBucketSeconds: 300, densityCapped: false, }, })); await page.route('**/api/stats/dashboard*', (r) => json(r, STATS())); await page.route('**/api/executions/ex-1', (r) => json(r, EXEC_DETAIL)); await page.goto('/operations'); + await expect(page.getByTitle(/Nightly Backup · Running/)).toBeVisible(); + // The track has to actually span 4 h for the aggregate to have anywhere to sit — the window + // the user picked is what sets the visible span, the snapshot only fills it. + await page.getByLabel('Window').selectOption('240'); - await expect(page.getByTestId('ops-truncated')).toBeVisible(); - // The band is anchored on the oldest row actually returned, not the requested edge. - await expect(page.getByTestId('ops-history-gap')).toBeVisible(); + // The whole point: at 4 h the stretch older than the newest bars carries the run counts + // instead of the hatched "nothing came back" band it used to show. + await expect(page.getByTestId('ops-density-cell')).toHaveCount(2); + await expect(page.getByTestId('ops-density-notice')).toContainText('32 finished runs'); + await expect(page.getByTestId('ops-density-notice')).toContainText('3 failed'); + await expect(page.getByTestId('ops-history-gap')).toHaveCount(0); }); test('folder filter scopes timeline and departure board together', async ({ page }) => { diff --git a/src/nodepilot-ui/index.html b/src/nodepilot-ui/index.html index 1c9c54d7..0fc53629 100644 --- a/src/nodepilot-ui/index.html +++ b/src/nodepilot-ui/index.html @@ -2,7 +2,10 @@ - + + NodePilot diff --git a/src/nodepilot-ui/src/__tests__/components/operations/OpsTimeline.test.tsx b/src/nodepilot-ui/src/__tests__/components/operations/OpsTimeline.test.tsx index 04208213..ff4355ca 100644 --- a/src/nodepilot-ui/src/__tests__/components/operations/OpsTimeline.test.tsx +++ b/src/nodepilot-ui/src/__tests__/components/operations/OpsTimeline.test.tsx @@ -19,6 +19,7 @@ function renderTimeline(overrides: Partial[0]> = nowMs={NOW} running={[{ executionId: 'run-1', workflowId: 'w1', status: 'Running', startedAt: new Date(NOW - 4 * MIN).toISOString(), parentExecutionId: null, stepsFinished: null, lastCompletedStepName: null, lastProgressAt: null, activeStepCount: null }]} recent={[{ executionId: 'done-1', workflowId: 'w2', status: 'Failed', startedAt: new Date(NOW - 10 * MIN).toISOString(), completedAt: new Date(NOW - 8 * MIN).toISOString(), parentExecutionId: null }]} + density={[]} locallySettled={{}} scopedWorkflowIds={new Set(['w1', 'w2'])} nodesById={NODES} @@ -27,7 +28,9 @@ function renderTimeline(overrides: Partial[0]> = overdueMs={10 * MIN} windowMs={20 * MIN} historyFromMs={null} - recentTruncated={false} + recentSinceMs={NOW - 20 * MIN} + densityBucketSeconds={0} + densityCapped={false} onSelect={onSelect} {...overrides} />, @@ -164,6 +167,95 @@ describe('OpsTimeline — overdue runs', () => { }); }); +describe('OpsTimeline — density for the stretch bars cannot reach', () => { + // The 4 h window on a busy system: ~4000 finished runs exist, the snapshot ships the newest + // 1000 as bars and the rest as counts. Before this, everything older than the newest ~30 min + // was an empty hatched band and the window selector was decoration. + const DENSITY_ARGS = { + running: [], + recent: [{ + executionId: 'newest', workflowId: 'w1', status: 'Succeeded', + startedAt: new Date(NOW - 31 * MIN).toISOString(), + completedAt: new Date(NOW - 30 * MIN).toISOString(), parentExecutionId: null, + }], + windowMs: 240 * MIN, + recentSinceMs: NOW - 240 * MIN, + historyFromMs: NOW - 30 * MIN, // seam: bars start here, density covers what is left of it + densityBucketSeconds: 300, + density: [{ + workflowId: 'w1', + buckets: [ + { bucketIndex: 3, total: 12, failed: 0, cancelled: 0 }, + { bucketIndex: 4, total: 20, failed: 3, cancelled: 1 }, + ], + }], + }; + + it('draws one cell per bucket left of the seam and names its runs in the tooltip', () => { + renderTimeline(DENSITY_ARGS); + const cells = screen.getAllByTestId('ops-density-cell'); + expect(cells).toHaveLength(2); + expect(cells[1].getAttribute('title')).toContain('20 runs'); + expect(cells[1].getAttribute('title')).toContain('3 failed'); + expect(cells[1].getAttribute('title')).toContain('1 cancelled'); + }); + + it('omits the zero counts from a clean bucket rather than printing "0 failed"', () => { + renderTimeline(DENSITY_ARGS); + const cells = screen.getAllByTestId('ops-density-cell'); + expect(cells[0].getAttribute('title')).toContain('12 runs'); + expect(cells[0].getAttribute('title')).not.toContain('failed'); + }); + + it('states the window total and suppresses the "no history" band', () => { + renderTimeline(DENSITY_ARGS); + // 12 + 20 counted, 3 of them failed — the answer to "what happened in these four hours?". + expect(screen.getByTestId('ops-density-notice').textContent).toContain('32 finished runs'); + expect(screen.getByTestId('ops-density-notice').textContent).toContain('3 failed'); + // The band claims nothing came back for that stretch; density is the refutation. + expect(screen.queryByTestId('ops-history-gap')).not.toBeInTheDocument(); + }); + + it('says so when the aggregate itself was capped', () => { + renderTimeline({ ...DENSITY_ARGS, densityCapped: true }); + expect(screen.getByTestId('ops-density-notice').textContent).toContain('More than'); + }); + + it('gives a workflow with density but no bar a lane of its own', () => { + // Every run of w2 fell past the raw cap. Without a lane its history would have nowhere to + // draw and the workflow would read as idle — the exact misreading density exists to prevent. + renderTimeline({ + ...DENSITY_ARGS, + density: [{ workflowId: 'w2', buckets: [{ bucketIndex: 5, total: 7, failed: 0, cancelled: 0 }] }], + }); + expect(screen.getByText('Report Gen')).toBeInTheDocument(); + expect(screen.getAllByTestId('ops-density-cell')).toHaveLength(1); + }); + + it('drops density for out-of-scope workflows, notice included', () => { + // w2 stays visible (so the lane view still renders) but w1's density must not leak into a + // folder-filtered board — nor into the run total the notice states. + renderTimeline({ + ...DENSITY_ARGS, + scopedWorkflowIds: new Set(['w2']), + recent: [{ + executionId: 'w2-run', workflowId: 'w2', status: 'Succeeded', + startedAt: new Date(NOW - 31 * MIN).toISOString(), + completedAt: new Date(NOW - 30 * MIN).toISOString(), parentExecutionId: null, + }], + }); + expect(screen.getByTestId('ops-timeline')).toBeInTheDocument(); + expect(screen.queryByTestId('ops-density-cell')).not.toBeInTheDocument(); + expect(screen.queryByTestId('ops-density-notice')).not.toBeInTheDocument(); + }); + + it('renders nothing extra on a quiet snapshot', () => { + renderTimeline(); + expect(screen.queryByTestId('ops-density-cell')).not.toBeInTheDocument(); + expect(screen.queryByTestId('ops-density-notice')).not.toBeInTheDocument(); + }); +}); + describe('OpsTimeline — duration stays comparable at wide windows', () => { // Regression: the bar-width floor used to be 6 px. At 1 h a 2-minute run renders ~9 px and a // 20-second one ~3 px, so the floor flattened both to the same length — every bar in the diff --git a/src/nodepilot-ui/src/__tests__/lib/opsTimeline.test.ts b/src/nodepilot-ui/src/__tests__/lib/opsTimeline.test.ts index f3849a02..8c3a5b84 100644 --- a/src/nodepilot-ui/src/__tests__/lib/opsTimeline.test.ts +++ b/src/nodepilot-ui/src/__tests__/lib/opsTimeline.test.ts @@ -1,7 +1,8 @@ import { describe, it, expect } from 'vitest'; import { windowFor, timeToX, buildTimelineBars, assignLanes, placeBar, axisTicks, isActiveBarStatus, - pairCallConnectors, isOverdue, isStalled, tickStepFor, OPS_WINDOW_MS, OPS_NOW_FRACTION, + pairCallConnectors, isOverdue, isStalled, tickStepFor, buildDensityCells, + OPS_WINDOW_MS, OPS_NOW_FRACTION, type TimelineBarInput, } from '../../lib/opsTimeline'; import type { OpsNode } from '../../types/api'; @@ -164,6 +165,86 @@ describe('assignLanes', () => { const { lanes } = assignLanes([orphan], nodes); expect(lanes[0].depth).toBe(0); }); + + it('gives a workflow with density but no bar a lane, sorted behind the ones that have bars', () => { + // Every run of w2 fell past the raw cap. No lane would mean its density has nowhere to draw + // and the workflow reads as idle — the exact misreading density exists to prevent. + const { lanes, placed } = assignLanes([bar('a', 'w1', 5, 2)], nodes, new Set(['w2'])); + expect(lanes.map((l) => l.workflowId)).toEqual(['w1', 'w2']); + // One row tall even with nothing in it, otherwise label and strip collapse to zero height. + expect(lanes[1].subRowCount).toBe(1); + expect(lanes[1].hasActive).toBe(false); + expect(placed).toHaveLength(1); + }); + + it('does not duplicate a lane that has both bars and density', () => { + const { lanes } = assignLanes([bar('a', 'w1', 5, 2)], nodes, new Set(['w1'])); + expect(lanes.map((l) => l.workflowId)).toEqual(['w1']); + expect(lanes[0].subRowCount).toBe(1); + }); +}); + +describe('buildDensityCells', () => { + // A 4 h window bucketed at 5 min, with the bar/aggregate seam 30 min before NOW. + const WIDE = windowFor(NOW, 240 * MIN, 1000); + const SINCE = NOW - 240 * MIN; + const SEAM = NOW - 30 * MIN; + const BUCKET_S = 300; + + const cells = (buckets: { bucketIndex: number; total: number; failed?: number; cancelled?: number }[]) => + buildDensityCells( + buckets.map((b) => ({ failed: 0, cancelled: 0, ...b })), + SINCE, BUCKET_S, SEAM, WIDE, + ); + + it('maps a bucket index onto the pixels of its time range', () => { + const [cell] = cells([{ bucketIndex: 2, total: 5 }]); + expect(cell.fromMs).toBe(SINCE + 2 * BUCKET_S * 1000); + expect(cell.toMs).toBe(SINCE + 3 * BUCKET_S * 1000); + expect(cell.leftPx).toBeCloseTo(timeToX(cell.fromMs, WIDE), 5); + expect(cell.widthPx).toBeCloseTo(timeToX(cell.toMs, WIDE) - cell.leftPx, 5); + }); + + it('clips the bucket that straddles the seam instead of drawing over the bars', () => { + // The seam is wherever the oldest returned run happens to sit — it does not land on a bucket + // boundary. Half of that bucket's runs are already bars; only the older half may be drawn. + const offSeam = NOW - 32.5 * MIN; + const straddled = Math.floor((offSeam - SINCE) / (BUCKET_S * 1000)); + const [cell] = buildDensityCells( + [{ bucketIndex: straddled, total: 9, failed: 0, cancelled: 0 }], + SINCE, BUCKET_S, offSeam, WIDE, + ); + expect(cell.toMs).toBe(offSeam); + expect(cell.fromMs).toBeLessThan(offSeam); + }); + + it('drops buckets that lie entirely right of the seam — those runs are already bars', () => { + const pastSeam = Math.floor((SEAM - SINCE) / (BUCKET_S * 1000)) + 1; + expect(cells([{ bucketIndex: pastSeam, total: 9 }])).toHaveLength(0); + }); + + it('drops empty buckets and keeps a sub-pixel one at least 1 px wide', () => { + // A slice that rounds away to nothing would read as a gap in the history — the opposite of + // what it says. A zero-count slice, on the other hand, has nothing to say at all. + expect(cells([{ bucketIndex: 3, total: 0 }])).toHaveLength(0); + const narrow = buildDensityCells( + [{ bucketIndex: 3, total: 1, failed: 0, cancelled: 0 }], + SINCE, 1, SEAM, WIDE, + ); + expect(narrow[0].widthPx).toBeGreaterThanOrEqual(1); + }); + + it('carries the outcome split through untouched', () => { + const [cell] = cells([{ bucketIndex: 1, total: 20, failed: 3, cancelled: 1 }]); + expect(cell).toMatchObject({ total: 20, failed: 3, cancelled: 1 }); + }); + + it('returns nothing before the track has been measured or without a bucket width', () => { + const unmeasured = windowFor(NOW, 240 * MIN, 0); + expect(buildDensityCells([{ bucketIndex: 1, total: 4, failed: 0, cancelled: 0 }], SINCE, BUCKET_S, SEAM, unmeasured)).toHaveLength(0); + expect(cells([{ bucketIndex: 1, total: 4 }]).length).toBeGreaterThan(0); + expect(buildDensityCells([{ bucketIndex: 1, total: 4, failed: 0, cancelled: 0 }], SINCE, 0, SEAM, WIDE)).toHaveLength(0); + }); }); describe('placeBar', () => { diff --git a/src/nodepilot-ui/src/__tests__/pages/AiChatPage.test.tsx b/src/nodepilot-ui/src/__tests__/pages/AiChatPage.test.tsx index 3d933c2e..537827e6 100644 --- a/src/nodepilot-ui/src/__tests__/pages/AiChatPage.test.tsx +++ b/src/nodepilot-ui/src/__tests__/pages/AiChatPage.test.tsx @@ -174,13 +174,27 @@ describe('AiChatPage', () => { askMock.mockImplementation(streamMock({ reply: 'ok' })); renderPage(); - // The first example is the webhook-trigger prompt. - fireEvent.click(screen.getByRole('button', { name: /webhook trigger/i })); + // Default caps have db:false → the lite set, whose second prompt is the webhook how-to. + // `find*`, not `get*`: the grid only mounts once capabilities resolve. + fireEvent.click(await screen.findByRole('button', { name: /webhook trigger/i })); await waitFor(() => expect(askMock).toHaveBeenCalledTimes(1)); expect((askMock.mock.calls[0][0] as KnowledgeAskRequest).question).toMatch(/webhook trigger/i); }); + it('offers the operational example prompts once the database source is available', async () => { + capsMock.mockResolvedValue({ enabled: true, docs: true, operational: true, sourceCode: false, db: true }); + askMock.mockImplementation(streamMock({ reply: 'ok' })); + renderPage(); + + // db:true swaps the whole slate: ops questions in, docs-only lite prompts out. + fireEvent.click(await screen.findByRole('button', { name: /failed runs/i })); + expect(screen.queryByRole('button', { name: /webhook trigger/i })).not.toBeInTheDocument(); + + await waitFor(() => expect(askMock).toHaveBeenCalledTimes(1)); + expect((askMock.mock.calls[0][0] as KnowledgeAskRequest).question).toMatch(/failed runs/i); + }); + it('isolates threads — "New chat" starts an empty thread', async () => { askMock.mockImplementation(streamMock({ reply: 'first answer' })); renderPage(); diff --git a/src/nodepilot-ui/src/__tests__/pages/OperationsPage.test.tsx b/src/nodepilot-ui/src/__tests__/pages/OperationsPage.test.tsx index 858f9f4b..4c4824ec 100644 --- a/src/nodepilot-ui/src/__tests__/pages/OperationsPage.test.tsx +++ b/src/nodepilot-ui/src/__tests__/pages/OperationsPage.test.tsx @@ -41,8 +41,9 @@ const GRAPH: OperationsGraph = { ], edges: [], running: [{ executionId: 'ex-1', workflowId: 'wf-1', status: 'Running', startedAt: new Date(NOW - 4 * MIN).toISOString(), parentExecutionId: null, stepsFinished: null, lastCompletedStepName: null, lastProgressAt: null, activeStepCount: null }], - meta: { overdueSeconds: 600, windowMinutes: 20, recentSinceUtc: new Date(0).toISOString(), oldestReturnedCompletedAt: null, recentTruncated: false }, + meta: { overdueSeconds: 600, windowMinutes: 20, recentSinceUtc: new Date(0).toISOString(), oldestReturnedCompletedAt: null, recentTruncated: false, densityBucketSeconds: 0, densityCapped: false }, recent: [{ executionId: 'ex-2', workflowId: 'wf-2', status: 'Failed', startedAt: new Date(NOW - 10 * MIN).toISOString(), completedAt: new Date(NOW - 8 * MIN).toISOString(), parentExecutionId: null }], + density: [], }; const STATS = { @@ -274,7 +275,7 @@ describe('OperationsPage', () => { it('shows the empty state when no accessible workflows', async () => { server.use(http.get(`${BASE}/api/operations/graph`, () => - HttpResponse.json({ nodes: [], edges: [], running: [], recent: [], meta: { overdueSeconds: 600, windowMinutes: 20, recentSinceUtc: new Date(0).toISOString(), oldestReturnedCompletedAt: null, recentTruncated: false } }))); + HttpResponse.json({ nodes: [], edges: [], running: [], recent: [], density: [], meta: { overdueSeconds: 600, windowMinutes: 20, recentSinceUtc: new Date(0).toISOString(), oldestReturnedCompletedAt: null, recentTruncated: false, densityBucketSeconds: 0, densityCapped: false } }))); renderPage(); expect(await screen.findByText('No accessible workflows.')).toBeInTheDocument(); }); @@ -282,7 +283,7 @@ describe('OperationsPage', () => { it('shows the idle hero when workflows exist but nothing ran recently', async () => { server.use(http.get(`${BASE}/api/operations/graph`, () => HttpResponse.json({ nodes: [node({ workflowId: 'wf-a', name: 'Alpha', folderId: 'f', folderPath: '/', lastStatus: 'Succeeded' })], - edges: [], running: [], recent: [], meta: { overdueSeconds: 600, windowMinutes: 20, recentSinceUtc: new Date(0).toISOString(), oldestReturnedCompletedAt: null, recentTruncated: false }, + edges: [], running: [], recent: [], density: [], meta: { overdueSeconds: 600, windowMinutes: 20, recentSinceUtc: new Date(0).toISOString(), oldestReturnedCompletedAt: null, recentTruncated: false, densityBucketSeconds: 0, densityCapped: false }, }))); renderPage(); expect(await screen.findByText('Nothing is running right now.')).toBeInTheDocument(); @@ -316,7 +317,7 @@ describe('OperationsPage', () => { // Next snapshot no longer exposes the /Prod folder (RBAC / scope change). server.use(http.get(`${BASE}/api/operations/graph`, () => HttpResponse.json({ nodes: [node({ workflowId: 'wf-3', name: 'Health Check', folderId: 'staging', folderPath: '/Staging', lastStatus: 'Succeeded' })], - edges: [], running: [], recent: [], meta: { overdueSeconds: 600, windowMinutes: 20, recentSinceUtc: new Date(0).toISOString(), oldestReturnedCompletedAt: null, recentTruncated: false }, + edges: [], running: [], recent: [], density: [], meta: { overdueSeconds: 600, windowMinutes: 20, recentSinceUtc: new Date(0).toISOString(), oldestReturnedCompletedAt: null, recentTruncated: false, densityBucketSeconds: 0, densityCapped: false }, }))); await qc.invalidateQueries({ queryKey: ['operations-graph'] }); @@ -329,7 +330,7 @@ describe('OperationsPage', () => { nodes: folders.map((folderId, i) => node({ workflowId: `wf-${i}`, name: `WF ${i}`, folderId, folderPath: `/${folderId}`, lastStatus: 'Succeeded', })), - edges: [], running: [], recent: [], meta: { overdueSeconds: 600, windowMinutes: 20, recentSinceUtc: new Date(0).toISOString(), oldestReturnedCompletedAt: null, recentTruncated: false }, + edges: [], running: [], recent: [], density: [], meta: { overdueSeconds: 600, windowMinutes: 20, recentSinceUtc: new Date(0).toISOString(), oldestReturnedCompletedAt: null, recentTruncated: false, densityBucketSeconds: 0, densityCapped: false }, }))); renderPage(); const select = await screen.findByLabelText('Folder') as HTMLSelectElement; diff --git a/src/nodepilot-ui/src/components/operations/OpsTimeline.tsx b/src/nodepilot-ui/src/components/operations/OpsTimeline.tsx index cf0d7585..9c921d6d 100644 --- a/src/nodepilot-ui/src/components/operations/OpsTimeline.tsx +++ b/src/nodepilot-ui/src/components/operations/OpsTimeline.tsx @@ -1,12 +1,13 @@ import { useLayoutEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Activity } from '@carbon/icons-react'; -import type { OpsNode, OpsRecentExecution, OpsRunningExecution } from '../../types/api'; +import type { OpsDensityLane, OpsNode, OpsRecentExecution, OpsRunningExecution } from '../../types/api'; import type { LocalSettled } from '../../stores/operationsStore'; -import { rawStatusLabelKey, STATUS_TEXT_CLASS } from '../../lib/statusTokens'; +import { rawStatusLabelKey, STATUS_COLOR_VAR, STATUS_TEXT_CLASS } from '../../lib/statusTokens'; import { windowFor, buildTimelineBars, assignLanes, placeBar, axisTicks, timeToX, pairCallConnectors, - isActiveBarStatus, isOverdue, isStalled, tickStepFor, formatDuration, type PlacedBar, + buildDensityCells, isActiveBarStatus, isOverdue, isStalled, tickStepFor, formatDuration, + type DensityCell, type PlacedBar, } from '../../lib/opsTimeline'; import { OpsTimelineBar, OPS_ROW_H, OPS_MIN_BAR_PX, OPS_INSIDE_LABEL_PX } from './OpsTimelineBar'; import { OpsStuckStrip } from './OpsStuckStrip'; @@ -25,11 +26,46 @@ const LANE_GAP = 8; const LABEL_COL_PX = 380; /** Room the out-of-bar duration text needs before it is worth drawing. */ const OUTSIDE_LABEL_PX = 46; +/** Vertical padding of a density slice inside its lane, so lanes stay visually separable. */ +const DENSITY_INSET = 5; -export function OpsTimeline({ nowMs, running, recent, locallySettled, scopedWorkflowIds, nodesById, selectedExecutionId, nextStart, overdueMs, windowMs, historyFromMs, recentTruncated, onSelect }: Readonly<{ +/** + * Vertical stack of a density slice's outcomes: failures at the bottom, cancellations above them, + * successes filling the rest — each sized by its share of the slice. A single "worst status" + * colour was the obvious alternative and is a lie in both directions: it paints nineteen good + * runs red, or hides one bad run among them. + */ +function densityBackground(cell: DensityCell): string { + const failedPct = (cell.failed / cell.total) * 100; + const cancelledPct = ((cell.failed + cell.cancelled) / cell.total) * 100; + if (cancelledPct === 0) return STATUS_COLOR_VAR.success; + return `linear-gradient(to top,` + + ` ${STATUS_COLOR_VAR.failed} 0 ${failedPct}%,` + + ` ${STATUS_COLOR_VAR.cancelled} ${failedPct}% ${cancelledPct}%,` + + ` ${STATUS_COLOR_VAR.success} ${cancelledPct}% 100%)`; +} + +/** + * Run count → ink. Scaled against the busiest slice in the same snapshot rather than an absolute + * number, because "busy" only means anything relative to the rest of what is on screen. Floored + * well above zero so a quiet slice stays visible: the difference that matters most is between + * "few runs" and "no runs at all". + */ +function densityOpacity(total: number, peak: number): number { + if (peak <= 0) return 0.85; + return 0.28 + 0.57 * (total / peak); +} + +export function OpsTimeline({ nowMs, running, recent, density, locallySettled, scopedWorkflowIds, nodesById, selectedExecutionId, nextStart, overdueMs, windowMs, historyFromMs, recentSinceMs, densityBucketSeconds, densityCapped, onSelect }: Readonly<{ nowMs: number; running: OpsRunningExecution[]; recent: OpsRecentExecution[]; + /** + * Bucketed run counts for the stretch the bars could not reach. Empty whenever `recent` already + * covers the window, which is the normal case — density is what a busy system degrades to, not + * a second rendering mode the view flips between. + */ + density: OpsDensityLane[]; locallySettled: Record; scopedWorkflowIds: Set; nodesById: Map; @@ -41,12 +77,16 @@ export function OpsTimeline({ nowMs, running, recent, locallySettled, scopedWork /** Visible span of the track. */ windowMs: number; /** - * Oldest settled run the server actually returned. When it lies inside the visible window, - * everything left of it is "no history returned", not "nothing ran" — the band says so. + * Oldest settled run the server actually returned — the seam between bars and density. When + * nothing is left of it, everything left of it is "no history returned", not "nothing ran". */ historyFromMs: number | null; - /** The server hit its cap: older settled runs inside the window exist but were not sent. */ - recentTruncated: boolean; + /** Left edge the snapshot was built for; density bucket 0 starts here. */ + recentSinceMs: number; + /** Width of one density bucket; 0 when the snapshot carries no density. */ + densityBucketSeconds: number; + /** Density was computed off the newest N runs only — the counts are a floor, not a total. */ + densityCapped: boolean; onSelect: (executionId: string) => void; }>) { const { t } = useTranslation(['operations', 'executions']); @@ -70,10 +110,19 @@ export function OpsTimeline({ nowMs, running, recent, locallySettled, scopedWork const w = useMemo(() => windowFor(nowMs, windowMs, trackWidth), [nowMs, windowMs, trackWidth]); + // Density arrives keyed by workflow; scope it once and reuse the key set for lane allocation. + const densityByWorkflow = useMemo(() => { + const map = new Map(); + for (const lane of density) { + if (scopedWorkflowIds.has(lane.workflowId)) map.set(lane.workflowId, lane.buckets); + } + return map; + }, [density, scopedWorkflowIds]); + const { lanes, placed } = useMemo(() => { const bars = buildTimelineBars(running, recent, locallySettled, w, scopedWorkflowIds); - return assignLanes(bars, nodesById); - }, [running, recent, locallySettled, w, scopedWorkflowIds, nodesById]); + return assignLanes(bars, nodesById, new Set(densityByWorkflow.keys())); + }, [running, recent, locallySettled, w, scopedWorkflowIds, nodesById, densityByWorkflow]); // Vertical offsets: lanes stack; each lane is subRowCount rows tall. const laneTops = useMemo(() => { @@ -159,14 +208,50 @@ export function OpsTimeline({ nowMs, running, recent, locallySettled, scopedWork const ticks = useMemo(() => axisTicks(w, tickStepFor(windowMs)), [w, windowMs]); + // Density cells per lane, covering the stretch left of the bar/aggregate seam. + const densityCellsByLane = useMemo(() => { + const seam = historyFromMs ?? nowMs; + const map = new Map(); + for (const [workflowId, buckets] of densityByWorkflow) { + const cells = buildDensityCells(buckets, recentSinceMs, densityBucketSeconds, seam, w); + if (cells.length > 0) map.set(workflowId, cells); + } + return map; + }, [densityByWorkflow, recentSinceMs, densityBucketSeconds, historyFromMs, nowMs, w]); + + // Busiest slice on screen — the reference the per-cell opacity is scaled against, so "darker" + // reliably means "more runs" within one snapshot. + const densityPeak = useMemo(() => { + let peak = 0; + for (const cells of densityCellsByLane.values()) { + for (const c of cells) if (c.total > peak) peak = c.total; + } + return peak; + }, [densityCellsByLane]); + + // Window-wide totals for the notice line. Summed over every bucket, INCLUDING the ones right of + // the seam that are not drawn — the server buckets the whole window precisely so this number is + // the honest answer to "how much ran here?", not just "how much was aggregated". + const densitySummary = useMemo(() => { + let runs = 0; + let failed = 0; + for (const buckets of densityByWorkflow.values()) { + for (const b of buckets) { runs += b.total; failed += b.failed; } + } + return { runs, failed }; + }, [densityByWorkflow]); + // Width of the "no history returned" band at the left edge, in px. Anchored on the oldest // row the server actually sent — NOT on the requested window edge: when the cap bit, the // requested edge would mark the one stretch truncation did not lose. + // + // Suppressed once density is present: the band claims "nothing came back for this stretch", + // and the density strip is exactly the refutation of that claim. const historyGapPx = useMemo(() => { - if (historyFromMs === null) return 0; + if (historyFromMs === null || densityCellsByLane.size > 0) return 0; const x = timeToX(historyFromMs, w); return x > 2 ? Math.min(x, w.trackWidthPx) : 0; - }, [historyFromMs, w]); + }, [historyFromMs, densityCellsByLane, w]); const nowX = timeToX(nowMs, w); const rowCenterY = (bar: PlacedBar) => @@ -177,6 +262,21 @@ export function OpsTimeline({ nowMs, running, recent, locallySettled, scopedWork return key ? t(`executions:status.${key}`) : status; }; + const clockLabel = (ms: number) => + new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); + + // Failed/cancelled are appended only when non-zero: a slice that reads "· 0 failed" trains the + // eye to skip the very part of the label that matters when it is not zero. + const densityTitle = (cell: DensityCell) => [ + t('operations:timeline.densityCell', { + from: clockLabel(cell.fromMs), + to: clockLabel(cell.toMs), + runs: cell.total, + }), + cell.failed > 0 ? t('operations:timeline.densityCellFailed', { count: cell.failed }) : null, + cell.cancelled > 0 ? t('operations:timeline.densityCellCancelled', { count: cell.cancelled }) : null, + ].filter(Boolean).join(' · '); + if (lanes.length === 0) { return (
@@ -205,9 +305,12 @@ export function OpsTimeline({ nowMs, running, recent, locallySettled, scopedWork nameFor={(id) => nodesById.get(id)?.name ?? id} onSelect={onSelect} /> - {recentTruncated && ( -

- {t('operations:timeline.recentTruncated')} + {densityCellsByLane.size > 0 && ( +

+ {t(densityCapped ? 'operations:timeline.densityCapped' : 'operations:timeline.density', { + runs: densitySummary.runs, + failed: densitySummary.failed, + })}

)}
@@ -277,6 +380,27 @@ export function OpsTimeline({ nowMs, running, recent, locallySettled, scopedWork data-testid="ops-history-gap" /> )} + {/* Density: what the window holds where individual bars ran out. One slice per + (lane, bucket), stacked by outcome so a slice with a single failure among twenty + successes cannot render as either all-green or all-red. */} + {lanes.flatMap((lane, i) => + (densityCellsByLane.get(lane.workflowId) ?? []).map((cell) => ( +
+ )), + )} {/* Gridlines */} {ticks.map((tick) => (